ThemeController | CoUI
본문으로 건너뛰기
LogoCoUI

ThemeController

팔레트 색상 프리뷰가 있는 테마 스와치를 그리드로 표시하는 통합 테마 선택 컴포넌트

목차

ThemeController는 선택 가능한 테마 스와치를 줄바꿈 그리드로 표시하는 통합 테마 선택 컴포넌트입니다. 각 스와치는 테마의 팔레트 색상을 원 하나로 나눠 미리보기로 보여주고 그 아래 테마 이름을 라벨로 붙이며, 선택 시 테마 이름을 onThemeChanged로 전달합니다. 크기는 size 사다리(sm / md / lg)로 고릅니다.

기본 사용법 (Basic Usage)#

ThemeController(
  selectedTheme: _selected,
  onThemeChanged: (name) => setState(() => _selected = name),
  themes: const [
    CoreThemeOption(
      name: 'Ocean',
      primaryColor: CoreColor.fromRGB(14, 165, 233),
      secondaryColor: CoreColor.fromRGB(99, 102, 241),
    ),
    CoreThemeOption(
      name: 'Forest',
      primaryColor: CoreColor.fromRGB(34, 197, 94),
      secondaryColor: CoreColor.fromRGB(132, 204, 22),
    ),
  ],
)
ThemeController(
  selectedTheme: _selected,
  onThemeChanged: (name) => setState(() => _selected = name),
  themes: const [
    CoreThemeOption(
      name: 'Ocean',
      primaryColor: CoreColor.fromRGB(14, 165, 233),
      secondaryColor: CoreColor.fromRGB(99, 102, 241),
    ),
    CoreThemeOption(
      name: 'Forest',
      primaryColor: CoreColor.fromRGB(34, 197, 94),
      secondaryColor: CoreColor.fromRGB(132, 204, 22),
    ),
  ],
)

빠른 오버라이드 (Chain)#

이미 만든 ThemeController 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class ThemeControllerChainExample extends StatefulWidget {
  const ThemeControllerChainExample({super.key});

  @override
  State<ThemeControllerChainExample> createState() => _ThemeControllerChainExampleState();
}

class _ThemeControllerChainExampleState extends State<ThemeControllerChainExample> {
  String _selected = 'Ocean';

  @override
  Widget build(BuildContext context) {
    return ThemeController(
      selectedTheme: _selected,
      onThemeChanged: (name) => setState(() => _selected = name),
      themes: const [
        CoreThemeOption(
          name: 'Ocean',
          primaryColor: CoreColor.fromRGB(14, 165, 233),
          secondaryColor: CoreColor.fromRGB(99, 102, 241),
        ),
        CoreThemeOption(
          name: 'Forest',
          primaryColor: CoreColor.fromRGB(34, 197, 94),
          secondaryColor: CoreColor.fromRGB(132, 204, 22),
        ),
        CoreThemeOption(
          name: 'Sunset',
          primaryColor: CoreColor.fromRGB(249, 115, 22),
          secondaryColor: CoreColor.fromRGB(239, 68, 68),
        ),
      ],
    ).withStyle(
      const CoreThemeControllerStyle(
        buttonSize: CoreSize.size64,
        buttonRadius: CoreBorderRadius.all(CoreRadius.radius4),
        selectedBorderColor: CoreColor.token(CoreColors.primary),
        selectedBorderWidth: CoreStrokeWidth.stroke3,
      ),
    );
  }
}
class ThemeControllerChainExample extends StatefulComponent {
  const ThemeControllerChainExample({super.key});

  @override
  State<ThemeControllerChainExample> createState() => _ThemeControllerChainExampleState();
}

class _ThemeControllerChainExampleState extends State<ThemeControllerChainExample> {
  String _selected = 'Ocean';

  @override
  Component build(BuildContext context) {
    return ThemeController(
      selectedTheme: _selected,
      onThemeChanged: (name) => setState(() => _selected = name),
      themes: const [
        CoreThemeOption(
          name: 'Ocean',
          primaryColor: CoreColor.fromRGB(14, 165, 233),
          secondaryColor: CoreColor.fromRGB(99, 102, 241),
        ),
        CoreThemeOption(
          name: 'Forest',
          primaryColor: CoreColor.fromRGB(34, 197, 94),
          secondaryColor: CoreColor.fromRGB(132, 204, 22),
        ),
        CoreThemeOption(
          name: 'Sunset',
          primaryColor: CoreColor.fromRGB(249, 115, 22),
          secondaryColor: CoreColor.fromRGB(239, 68, 68),
        ),
      ],
    ).withStyle(
      const CoreThemeControllerStyle(
        buttonSize: CoreSize.size64,
        buttonRadius: CoreBorderRadius.all(CoreRadius.radius4),
        selectedBorderColor: CoreColor.token(CoreColors.primary),
        selectedBorderWidth: CoreStrokeWidth.stroke3,
      ),
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 사용자가 여러 색상 테마 중 하나를 고르도록 할 때
  • 설정 화면에서 테마 미리보기와 함께 선택지를 제공할 때

대신 다른 컴포넌트를 사용하세요:

  • Toggle: 라이트/다크 두 가지만 전환할 때
  • Select: 색상 프리뷰 없이 텍스트 목록으로 충분할 때

Props / Parameters#

ThemeController#

속성타입기본값설명
themes List<CoreThemeOption> 필수 선택 가능한 테마 옵션 목록
selectedTheme String? null 현재 선택된 테마 이름
onThemeChanged void Function(String) 필수 테마 선택 핸들러 (옵션 이름 전달)
size CoreThemeControllerSize md 스와치 크기 사다리 — sm / md / lg 가 각각 32 / 40 / 48px 버튼. CoreThemeControllerStyle.defaultsBySize 의 항목을 고르는 시맨틱 식별자 (값 자체는 themeControllerStyle 로 오버라이드)
enabled bool true 컨트롤 전체의 활성 여부 — false 면 모든 스와치가 한꺼번에 잠김 (개별 테마는 CoreThemeOption.enabled)
themeControllerStyle CoreThemeControllerStyle? null 크롬 / 치수 스타일 오버라이드

CoreThemeOption#

속성타입기본값설명
name String 필수 테마 이름 (onThemeChanged 식별자)
primaryColorCoreColor필수주요 팔레트 색상 프리뷰
secondaryColor CoreColor? null 보조 팔레트 색상 프리뷰
accentColor CoreColor? null 액센트 팔레트 색상 프리뷰
neutralColor CoreColor? null 중립 팔레트 색상 프리뷰
enabled bool true 이 테마 하나를 고를 수 있는지 — false 면 자리는 유지한 채 팔레트 색을 버리고 잠김 (컨트롤 전체는 ThemeController.enabled )

크기 (Sizes)#

size 는 버튼·프리뷰·라벨을 한 벌로 묶은 사다리입니다. 버튼은 어느 단에서든 원이고(모서리가 한 변의 절반), 팔레트 원은 그 안쪽 4px 에 앉아 고리가 팔레트를 둘러싸는 모양입니다. 각 값은 themeControllerStyle 의 같은 이름 필드로 개별 오버라이드할 수 있습니다.

필드smmd (기본)lg
buttonSize32px40px48px
previewSize24px32px40px
buttonRadius16px20px24px
buttonSpacing12px12px12px
labelGapStyle.size6px6px6px
labelStyle labelSmall labelMedium labelLarge

스타일 시스템 (Style System)#

ThemeController 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreThemeControllerStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum (size) 과 behaviour 필드 (themes / selectedTheme / onThemeChanged / enabled) 는 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / behaviour: 위젯 파라미터로 직접 (size, enabled, themes, selectedTheme, onThemeChanged)
  • chrome / dimensional / 슬롯 스타일: CoreThemeControllerStyle 한 곳으로 (아래 필드 표 참고)
  • 팔레트 색: 스타일이 아니라 데이터 — CoreThemeOption 의 네 색은 호출자가 넘기는 값이고, 스타일은 그 원의 크기·모서리(previewSize / previewBorderRadius)와 잠겼을 때의 채움(disabledPreviewColor)만 정합니다.

Resolve chain#

CoreThemeControllerStyle.default*                // 사이즈 무관 default (leaf fallback)
  → CoreThemeControllerStyle.defaultsBySize[size] // 사다리 (sm / md / lg) — 체인의 base
  → CoreThemeControllerTheme.style                // 프로젝트 공통
  → widget.themeControllerStyle                   // 인스턴스별

각 nested 슬롯 스타일 (labelStyle / labelGapStyle / clickableStyle) 은 자기 컴포넌트(Icon / Text / Gap / Clickable)의 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreThemeControllerStyle 필드#

필드타입설명
buttonSize double? Size of each theme button (logical px).
previewSize double? Size of each colour-preview circle (logical px).
buttonSpacing double? Spacing between adjacent theme buttons (logical px) — consumed as native Wrap.spacing / Wrap.runSpacing on Flutter and as the flex container gap CSS property on Web.
swatchMinWidth double? Minimum width of one swatch cell (logical px) — the cell grows past it when the theme name is wider; see [defaultSwatchMinWidth].
buttonRadius CoreBorderRadius? Corner radius of a theme button.
previewBorderRadius CoreBorderRadius? Corner radius of the colour-preview circle inside a swatch button. Defaults to a fully round dot — see [defaultPreviewBorderRadius].
selectedBorderColor CoreColor? Border colour of the selected theme button.
unselectedBorderColor CoreColor? Border colour of an unselected theme button.
selectedBorderWidth double? Border thickness of the selected theme button (logical px).
unselectedBorderWidth double? Border thickness of an unselected theme button (logical px).
hoverBorderColor CoreColor? Ring colour of an unselected, enabled swatch while it is hovered or pressed. The selected swatch keeps [selectedBorderColor] in those states; a disabled one reacts to neither.
hoverBorderWidth double? Ring thickness of an unselected, enabled swatch while it is hovered or pressed (logical px).
buttonBackgroundColor CoreColor? Background of a swatch button.
pressedPreviewShrink double? How much the palette circle's diameter shrinks while its swatch is pressed (logical px). The corner radius is [previewBorderRadius] — fully round — so the circle stays a circle at the smaller size.
disabledBorderColor CoreColor? Border colour of a disabled swatch button.
disabledPreviewColor CoreColor? Fill of every palette segment on a disabled swatch.
disabledLabelColor CoreColor? Label colour on a disabled swatch.
labelStyle CoreTextStyle? Nested [CoreTextStyle] slot for the swatch label — typography only; the colour lives in [labelColor] / [selectedLabelColor] / [disabledLabelColor].
labelColor CoreColor? Colour of the swatch label while the swatch is interactive and not selected.
selectedLabelColor CoreColor? Colour of the label of the selected, interactive swatch.
labelGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between a swatch button and its label.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed per-swatch Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.

동작 스펙 (Behavior)#

인터랙션#

  • 선택: 스와치 클릭 시 onThemeChanged(name) 호출
  • 선택 표시: 선택된 스와치는 팔레트를 둘러싼 강한 고리(selectedBorderColor = onSurface, 2px)를 두르고 라벨이 같은 잉크(selectedLabelColor)로 진해집니다. 선택되지 않은 활성 스와치는 쉬는 동안 고리가 없습니다(unselectedBorderWidth = 0).
  • 호버 · 눌림: 선택되지 않은 활성 스와치에 옅은 고리(hoverBorderColor = outlineVariant, hoverBorderWidth = 2px)가 생깁니다. 눌리는 동안에는 팔레트 원이 지름 pressedPreviewShrink(2px)만큼 작아집니다. 선택된 스와치는 호버 · 눌림에서도 선택 고리를 지키고(눌림에서 팔레트만 줄어듭니다), 잠긴 스와치는 어느 쪽에도 반응하지 않습니다.

팔레트 프리뷰#

스와치 하나는 원 하나를 그립니다. 그 원은 옵션이 가진 색 수만큼 등분됩니다 — primaryColor 와 secondaryColor 두 색이면 반원 둘(캔버스가 그리는 그림), accentColor · neutralColor 까지 채우면 3등분 · 4등분. 색을 따로 점으로 늘어놓지 않고 한 원을 나누는 이유는, 스와치가 "토큰 몇 개의 나열" 이 아니라 "테마 하나" 로 읽혀야 하고, 색을 몇 개 넘기든 프리뷰 크기가 사다리의 previewSize 그대로여야 하기 때문입니다.

선택 표시 — 배지 없이 고리로#

선택은 배지가 아니라 팔레트를 둘러싼 고리가 말합니다. 고리는 그림자가 아니라 진짜 테두리(border)이므로, 색을 시스템 색으로 강제하는 모드(Windows 고대비 · forced-colors)에서도 시스템 테두리 색으로 남아 선택된 스와치를 다른 스와치와 구별합니다. 선택된 스와치는 호출자가 넘긴 임의 팔레트 위에 놓이므로, 고리는 브랜드 색이 아니라 강한 잉크(onSurface)입니다. 선택 상태는 색에만 의존하지 않고 접근성 속성으로도 전달됩니다 — Flutter 는 Semantics(selected:), Web 은 aria-pressed.

비활성 (두 범위)#

잠금은 두 범위로 나뉘고, 둘은 합쳐집니다 — 어느 하나만 false 여도 그 스와치는 잠깁니다.

  • 컨트롤 전체 — ThemeController.enabled: false. 읽기 전용 폼 구획처럼 그룹 전체를 한꺼번에 잠글 때.
  • 개별 테마 — CoreThemeOption.enabled: false. 현재 플랜에 없는 테마처럼 한 항목만 잠글 때. 잠긴 스와치는 줄에서 자리를 지킵니다 — 존재하는 테마이긴 하므로.

잠긴 스와치는 opacity 로 흐려지지 않고 전용 토큰으로 상태를 말합니다. 선택된 잠긴 스와치의 고리는 disabledBorderColor(disabledOutline, 2px 폭 유지), 팔레트의 모든 세그먼트는 색을 버리고 disabledPreviewColor(disabledContainer), 라벨은 disabledLabelColor(disabledContent). 선택되지 않은 잠긴 스와치는 쉬는 모양 그대로 고리가 없습니다. 팔레트 색을 버리는 이유는, 색이 남아 있으면 "아직 고를 수 있다" 고 말하는 셈이기 때문입니다.

칸 폭 · 간격#

스와치 한 칸(버튼 · 간격 · 라벨을 쌓은 열)은 최소 폭 swatchMinWidth(48px) 이상입니다. 고정 폭이 아니라 바닥이라서, 라벨이 짧으면 버튼과 라벨이 48px 칸의 가운데에 서고, 테마 이름이 더 길면 그 칸만 이름 폭만큼 넓어집니다 — 이름은 말줄임 없이 끝까지 보입니다. 칸 사이 간격(buttonSpacing)은 줄 방향과 줄 사이 모두 어느 단에서나 12px 입니다.

라벨#

버튼 아래에 labelGapStyle 간격을 두고 CoreThemeOption.name 이 labelStyle 로 붙습니다. 스와치 하나의 높이는 버튼 + 간격 + 라벨 한 줄 — md 기준 40px + 6px + labelMedium 한 줄입니다. 간격은 어느 단에서나 6px 입니다.

사용 가이드라인 (Usage Guidelines)#

이름 붙은 테마 옵션을 팔레트 스와치로 고르려면 ThemeController를 사용합니다. 일반적인 단일 선택 목록은 RadioGroup나 Select, 임의의 색 하나를 편집하려면 ColorPicker를 선택합니다. ThemeController는 옵션 이름만 통보하며 앱 테마 적용과 selectedTheme 갱신은 호출자가 담당합니다.

✅ Do#

selectedTheme 값을 themes 목록의 name 과 정확히 일치시키기

ThemeController(
  selectedTheme: 'Ocean', // themes 안 CoreThemeOption.name 과 동일해야 함
  onThemeChanged: (name) => setState(() => _selected = name),
  themes: const [
    CoreThemeOption(name: 'Ocean', primaryColor: ...),
    CoreThemeOption(name: 'Forest', primaryColor: ...),
  ],
)

이유: 선택 표시는 selectedTheme 문자열과 각 CoreThemeOption.name 을 비교해 결정되므로, 값이 다르면 어떤 스와치에도 선택 고리가 표시되지 않습니다.


❌ Don't#

라이트/다크 2택 전환에 사용하지 않기

// ❌ 두 개짜리 선택지에 그리드 스와치 선택기 사용
ThemeController(
  themes: const [
    CoreThemeOption(name: 'Light', primaryColor: ...),
    CoreThemeOption(name: 'Dark', primaryColor: ...),
  ],
  selectedTheme: mode,
  onThemeChanged: (name) => setMode(name),
)

이유: ThemeController는 여러 팔레트 중 하나를 고르는 그리드 UI로 설계되어 있습니다. 두 상태만 전환할 때는 Toggle이 더 적은 탭 동작과 명확한 on/off 시맨틱을 제공합니다.

접근성 (Accessibility)#

역할#

  • Flutter: 스와치 하나에 두 겹의 Semantics 가 겹칩니다 — 합성된 Clickable 이 Semantics(enabled: ..., button: true) 를 붙이고, ThemeController 자신이 Semantics(selected: isSelected, button: true, label: option.name) 를 붙입니다. 결과적으로 각 스와치는 테마 이름을 가진 버튼으로 선택 여부와 함께 안내됩니다.
  • Web: 스와치마다 Clickable 이 <div role="button" tabindex="0"> 를 내고, ThemeController 가 그 위에 aria-label(테마 이름)과 aria-pressed(선택 여부)를 얹습니다. Clickable 은 caller attributes 를 마지막에 병합하므로 이 두 값이 그대로 살아남고, role="button" 은 덮이지 않아 유지됩니다.
  • 루트에는 역할이 없습니다. Flutter 루트는 맨 Wrap 이고, Web 루트 <div> 는 configureAttributes 를 override 하지 않아 호출자가 넘긴 attributes 외에는 아무 role / aria-* 도 붙지 않습니다.

키보드#

키FlutterWeb
Enter / Space 포커스된 스와치 활성화 → onThemeChanged(name) ( Clickable 이 자기 ActivateIntent 매핑을 소유) 동일 (Clickable 의 keydown 이 Enter / Space 를 직접 처리)
Tab / Shift+Tab 스와치 사이 이동 — 스와치마다 정지점이 하나씩 동일 (Clickable 이 enabled 면 tabindex="0")
↑ ↓ ← → 컴포넌트가 처리하지 않음. 네이티브 타깃에서는 프레임워크 기본값이 방향 포커스 이동으로 받고, Flutter Web 에서는 같은 키가 스크롤로 매핑 되어 스와치 사이 이동이 되지 않습니다 처리하지 않음 — 아무 일도 일어나지 않습니다
Home / End / Escape없음없음

스크린 리더#

  • 이름의 출처는 CoreThemeOption.name 하나입니다 — Flutter 는 Semantics.label, Web 은 aria-label 로 같은 문자열을 씁니다. 팔레트 색 자체는 읽히지 않으므로 그 테마를 설명하는 텍스트는 name 이 전부입니다.
  • 선택 상태는 색과 모양에 의존하지 않고 플랫폼 어휘로 각각 말합니다 — Web 은 aria-pressed 로 "눌린 토글 버튼", Flutter 는 selected 플래그로 "선택됨". 같은 사실을 다른 단어로 전달합니다.
  • 잠긴 스와치는 Flutter Semantics(enabled: false) / Web aria-disabled="true" 로 비활성 상태를 함께 전달하고 활성화되지 않습니다.
  • live region 이 없습니다. 스와치를 활성화하면 포커스가 그 스와치에 있으므로 리더가 눌림 상태 변화를 읽지만, 직전까지 선택돼 있던 스와치가 해제되는 것과 앱 테마가 실제로 바뀌었다는 사실은 어디에서도 통보되지 않습니다 — onThemeChanged 이후는 호출자 몫이고 컴포넌트에 통보 표면이 없습니다.

포커스 관리#

  • 진입 — 이동 없음. 오버레이가 아니라 인라인 그리드라 마운트 시 포커스를 옮기지 않습니다 (양 플랫폼 build 어디에도 autofocus / requestFocus 호출이 없습니다).
  • 이탈 — 해당 없음(닫히는 표면이 아닙니다). 스와치를 활성화해도 포커스는 그 스와치에 그대로 남습니다.
  • 트랩 — 없음. Tab 은 마지막 스와치를 지나 다음 요소로 그대로 빠져나갑니다. 포커스 링은 양쪽 다 Clickable 이 제공합니다 (Flutter FocusOutline, Web :focus-visible).

알려진 제약#

  • 스와치들이 하나의 선택 그룹이라는 사실이 안내되지 않습니다. 위 "역할" 대로 루트에 역할이 없어 스와치들은 서로 무관한 토글 버튼 N 개로 읽히고, "N 개 중 1 번째" 같은 위치 안내가 나오지 않습니다. 대안은 있습니다 — Web 은 컴포넌트가 루트에 어떤 role 도 정의하지 않으므로 ThemeController(attributes: {'role': 'group', 'aria-label': '테마'}) 가 그대로 통과하고, Flutter 는 호출부에서 Semantics(container: true, label: '테마', child: ThemeController(...)) 로 감싸면 됩니다.
  • 스와치마다 tab stop 이 하나씩 생깁니다. roving tabindex 가 적용되어 있지 않아 테마가 N 개면 그리드를 지나가는 데 Tab 을 N 번 눌러야 합니다. 대안은 없습니다 — Web Clickable 은 enabled 이면 무조건 tabindex="0" 를 넣고, ThemeController 가 스와치별 attributes 를 자기가 채워 호출자가 tabindex 를 고정할 진입점이 없습니다.

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명ThemeControllerThemeController
렌더 루트Wrap<div> (flex flex-wrap)
스와치 버튼 Clickable > OutlinedContainer (바탕 없는 고리) Clickable > <div> (바탕 없는 고리)
호버 · 눌림 Clickable.onHover / onPressedChange 로 고리와 팔레트 크기를 고름 scoped <style> 규칙 — :hover / :active 의 고리( !important )와 팔레트 transform: scale()
팔레트 ClipRRect > Row of Expanded ColoredBox <div> (flex, overflow-hidden) > flex-1 세그먼트 × N
라벨 Gap + Text Gap + <div> > Text
  • Toggle: 라이트/다크 2가지 전환에 사용
  • Select: 색상 프리뷰 없는 텍스트 목록 선택