FocusOutline#
focused 가 true 일 때 child 둘레에 포커스 링을 그리는 순수 시각 래퍼(인프라)입니다. 링만 그릴 뿐 포커스 추적은
호출자 책임입니다 — 올바른 "포커스" 정의가 호출 지점마다 다르기 때문(텍스트 필드는 _focusNode.hasFocus, 리스트 항목은 선택 인덱스 등). 입력·버튼 등이 내부적으로 사용합니다.
Live Preview#
class FocusOutlineDefaultExample extends StatelessComponent {
const FocusOutlineDefaultExample({super.key});
@override
Component build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
);
}
}
class FocusOutlineDefaultExample extends StatelessWidget {
const FocusOutlineDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
);
}
}
class FocusOutlineChainExample extends StatelessComponent {
const FocusOutlineChainExample({super.key});
@override
Component build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
)
.withStyle(
const CoreFocusOutlineStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderWidth: CoreStrokeWidth.stroke3,
duration: Duration(milliseconds: CoreDuration.fast),
),
)
.radius16;
}
}
class FocusOutlineChainExample extends StatelessWidget {
const FocusOutlineChainExample({super.key});
@override
Widget build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
)
.withStyle(
const CoreFocusOutlineStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderWidth: CoreStrokeWidth.stroke3,
duration: Duration(milliseconds: CoreDuration.fast),
),
)
.radius16;
}
}
사용법#
FocusOutline(
focused: true, // 호출자가 추적한 포커스 상태
child: Card(child: Text('포커스된 요소').bodyMedium),
)
빠른 오버라이드 (Chain)#
이미 만든 FocusOutline 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class FocusOutlineChainExample extends StatelessWidget {
const FocusOutlineChainExample({super.key});
@override
Widget build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
)
.withStyle(
const CoreFocusOutlineStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderWidth: CoreStrokeWidth.stroke3,
duration: Duration(milliseconds: CoreDuration.fast),
),
)
.radius16;
}
}
class FocusOutlineChainExample extends StatelessComponent {
const FocusOutlineChainExample({super.key});
@override
Component build(BuildContext context) {
return FocusOutline(
focused: true,
child: Card(child: Text('포커스된 요소').bodyMedium),
)
.withStyle(
const CoreFocusOutlineStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderWidth: CoreStrokeWidth.stroke3,
duration: Duration(milliseconds: CoreDuration.fast),
),
)
.radius16;
}
}
Props#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
required | 링으로 감쌀 콘텐츠 |
focused |
bool |
false |
링 표시 여부 (호출자가 추적) |
shape |
CoreFocusOutlineShape |
rectangle |
링 모양 (rectangle / circle) |
focusOutlineStyle |
CoreFocusOutlineStyle? |
null |
링 색·두께·offset·radius 단일 진입점 |
스타일 시스템 (Style System)#
CoreFocusOutlineStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
align |
double? |
Distance from the child's edge to the ring's outer edge (logical px, pre-scaling). |
borderRadius |
CoreBorderRadius? |
Border radius override (rectangle shape only).
No
defaultBorderRadius
, deliberately — the right value belongs to whatever this ring is drawn around, so no constant can hold it.
Hosts inject their own resolved radius into this slot (
Button
passes
focusOutlineStyle?.borderRadius ?? <its own resolved radius>
; the subfocus infra passes its focus-ring radius), and a host that wants square ring corners simply leaves it null: Flutter's ring-radius helper returns
.zero
on null, and Web spreads
'border-radius': ?radius
so the rectangle arm emits no property at all. A constant would round every focus ring in the kit — including the ones deliberately drawn around square children — and would be the wrong number for each host besides (
radius8
buttons,
radius16
cards). Contrast [circleBorderRadius], which
does
have a default: the circle shape has one correct radius (full pill) regardless of host.
@hostDerived
tells the Figma reader this is "value comes from the host", not "nothing is drawn" — otherwise it exports the field as an absence and the canvas is asked to show square corners.
|
circleBorderRadius |
CoreBorderRadius? |
Border radius override applied when the focus outline
shape
is circle. Defaults to [defaultCircleBorderRadius] (all-9999 token) so the box-shadow ring follows a full pill; designers can override to tune the circular ring curvature.
|
borderColor | CoreColor? | Ring colour override. |
borderWidth |
double? |
Ring width override (logical px, pre-scaling). |
offsetColor |
CoreColor? |
Offset background colour override — the colour that fills the gap between the child's edge and the ring's inner edge. Web equivalent: Tailwind
ring-offset-<color>
.
|
duration |
Duration? |
Animation duration for the focus ring fade transition. |
ringOpacity |
double? |
Focus-ring opacity multiplier override.
null
→ [defaultRingOpacity]. Applied by
FocusOutline
's own resolver via
CoreColor.scaleAlpha
on top of whichever
borderColor
is in effect (default or per-instance override) — setting
ringOpacity
alone, without also touching
borderColor
, dims the ring on both platforms.
|
사용 가이드라인 (Usage Guidelines)#
✅ Do#
호출자가 추적한 실제 포커스 상태를 focused에 전달
FocusOutline(
focused: focusNode.hasFocus,
shape: CoreFocusOutlineShape.circle,
child: Avatar(initials: 'JD'),
)
FocusOutline은 자체 FocusNode를 갖지 않는 순수 시각 레이어입니다 — 링이 정확한 순간에 나타나려면 child를 실제로 포커스 가능하게 만드는 위젯의 포커스 상태를 그대로 넘겨야 합니다. 원형 콘텐츠에는 shape: circle을 지정해 링이 사각 모서리로 어색하게 그려지지 않게 합니다.
❌ Don't#
focused 값만으로 child가 키보드 포커스를 받는다고 가정하지 않기
// ❌ Container에는 Focus/FocusNode가 없어 Tab으로 도달할 수 없음
FocusOutline(
focused: isActive,
child: Container(color: Colors.blue),
)
FocusOutline은 IgnorePointer로 감싸인 장식 레이어일 뿐이라 hit-test 도, 포커스 요청도 하지 않습니다. child 자체가 Focus / FocusableActionDetector / 네이티브 버튼 등으로 실제 포커스를 받을 수 있어야 링이 의미를 가집니다.
접근성 (Accessibility)#
역할 / 시맨틱#
양 플랫폼 모두 아무 role 도 emit 하지 않습니다. Flutter 는 링을 Stacks / Position
/ IgnorePointer / AnimatedOpacity 로 쌓은 장식용 박스라 시맨틱 노드를 만들지 않고, Web 은 role
/ aria-* 가 없는 <div> 하나입니다. FocusOutline 은 순수 시각 레이어이며 접근성 트리에는 존재하지 않습니다.
키보드#
처리하는 키가 없습니다. 양 플랫폼 모두 키 핸들러를 등록하지 않습니다.
포커스#
이 컴포넌트는 포커스를 감지·요청·보유·트랩하지 않습니다. 이름과 달리 포커스에 관여하는 코드가 없습니다 — focused 는 기본값
false 인 평범한 호출자 제공 bool 이고, 링은 그 값으로 계산된 그림자·장식 레이어일 뿐입니다.
-
Flutter:
FocusNode/Focus/FocusScope/FocusableActionDetector가 없습니다. 링 레이어는IgnorePointer로 감싸여 hit-test 를 가져가지 않습니다. -
Web:
tabindex가 없고:focus/:focus-visibleCSS 선택자도 없습니다. 링이 브라우저 네이티브 포커스에 반응하지 않습니다.
child 를 포커스 가능하게 만드는 것도, 포커스 상태를 추적해 focused 를 갱신하는 것도 전적으로 호출자 책임입니다.
스크린 리더#
아무것도 추가하지 않습니다. child 는 이 래퍼가 없을 때와 정확히 동일하게 읽히며, 화면에 보이는 링에 대응하는 보조기술 신호는 양 플랫폼 모두 없습니다.
알려진 제약#
-
호출자가
focused: true로 다시 렌더하지 않으면 링은 나타나지 않습니다. Web 은:focus-visible규칙이 없어 네이티브 포커스를 스스로 따라가지 못합니다. -
키보드 포커스와 포인터 포커스를 구분하지 못합니다 — 그 판단은 호출자의
bool안에만 존재합니다.:focus-visible에 해당하는 동작이 필요하면 호출자가 직접 구현해야 합니다. -
Flutter 의 링은
Stacks오버레이로child바깥에 그려지며 레이아웃 공간을 예약하지 않으므로, 인접 콘텐츠와 겹칠 수 있습니다.
포커스 표시가 강제 색상 모드에서 어떻게 다뤄지는지 등 전 컴포넌트 공통 사항은 전역 접근성 축을 참고하세요.