Diff#
두 개의 컨텐츠를 한 프레임 안에 겹쳐 놓고 가운데 분할선을 드래그해서 좌/우
비율을 조절하면서 비교하는 viewer 입니다. daisyUI 의 diff 컴포넌트와 동일한
사용 의도이며, 이미지·텍스트·임의 위젯을 슬롯으로 받습니다.
코드 변경 사항을 라인 단위로 비교하려면
CodeDiff를 사용하세요.
Live Preview#
class DiffDefaultExample extends StatelessComponent {
const DiffDefaultExample({super.key});
@override
Component build(BuildContext context) {
return const Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
),
after: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
),
);
}
}
class _DiffImg extends StatelessComponent {
const _DiffImg({required this.url});
final String url;
@override
Component build(BuildContext context) {
return img(
src: url,
alt: '',
classes: 'not-content',
styles: const Styles(
raw: {
'display': 'block',
'width': '100%',
'height': '100%',
'object-fit': 'cover',
},
),
);
}
}
class DiffDefaultExample extends StatelessWidget {
const DiffDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
fit: BoxFit.cover,
),
after: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
fit: BoxFit.cover,
),
);
}
}
class DiffTextExample extends StatelessComponent {
const DiffTextExample({super.key});
@override
Component build(BuildContext context) {
final cs = context.colorScheme;
return Diff(
ariaLabel: 'Filled vs surface text comparison',
before: Component.element(
tag: 'div',
classes: 'w-full h-full grid place-content-center bg-${cs.primary}',
children: [Text('CoUI').displayLarge.onPrimary],
),
after: Component.element(
tag: 'div',
classes:
'w-full h-full grid place-content-center '
'bg-${cs.surfaceContainer}',
children: [Text('CoUI').displayLarge.onSurface],
),
);
}
}
class DiffTextExample extends StatelessWidget {
const DiffTextExample({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Diff(
ariaLabel: 'Filled vs surface text comparison',
before: ColoredBox(
color: colors.primary.toValue(),
child: Center(child: const Text('CoUI').displayLarge.onPrimary),
),
after: ColoredBox(
color: colors.surfaceContainer.toValue(),
child: Center(child: const Text('CoUI').displayLarge.onSurface),
),
);
}
}
class DiffChainExample extends StatelessComponent {
const DiffChainExample({super.key});
@override
Component build(BuildContext context) {
return const Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
),
after: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
),
)
.withStyle(
const CoreDiffStyle(
resizerColor: CoreColor.token(CoreColors.tertiary),
resizerWidth: CoreStrokeWidth.stroke4,
aspectRatio: 4 / 3,
),
)
.radius16;
}
}
class _DiffImg extends StatelessComponent {
const _DiffImg({required this.url});
final String url;
@override
Component build(BuildContext context) {
return img(
src: url,
alt: '',
// `not-content` opts out of the docs `prose` styling which
// otherwise injects `margin: 2em` and `height: auto` on every
// `<img>` inside the article body.
classes: 'not-content',
styles: const Styles(
raw: {
'display': 'block',
'width': '100%',
'height': '100%',
'object-fit': 'cover',
},
),
);
}
}
class DiffChainExample extends StatelessWidget {
const DiffChainExample({super.key});
@override
Widget build(BuildContext context) {
return Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
fit: BoxFit.cover,
),
after: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
fit: BoxFit.cover,
),
)
.withStyle(
const CoreDiffStyle(
resizerColor: CoreColor.token(CoreColors.tertiary),
resizerWidth: CoreStrokeWidth.stroke4,
aspectRatio: 4 / 3,
),
)
.radius16;
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 이미지 before/after 비교 (보정 전/후, 라이트/다크, 디자인 시안 비교)
- 폰트나 디자인 토큰 두 가지를 동일한 텍스트로 비교
- 어떤 두 컨텐츠든 좌/우 분할선으로 비율을 조절하며 보고 싶을 때
대신 다른 컴포넌트를 사용하세요:
CodeDiff: 코드 변경 사항을 라인 단위로 색상 강조 표시할 때Carousel: 여러 컨텐츠를 순차적으로 넘기며 볼 때
빠른 오버라이드 (Chain)#
이미 만든 Diff 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius9999처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius9999 ==
CoreRadius.radius9999) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class DiffChainExample extends StatelessWidget {
const DiffChainExample({super.key});
@override
Widget build(BuildContext context) {
return Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
fit: BoxFit.cover,
),
after: Image.network(
'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
fit: BoxFit.cover,
),
)
.withStyle(
const CoreDiffStyle(
resizerColor: CoreColor.token(CoreColors.tertiary),
resizerWidth: CoreStrokeWidth.stroke4,
aspectRatio: 4 / 3,
),
)
.radius16;
}
}
class DiffChainExample extends StatelessComponent {
const DiffChainExample({super.key});
@override
Component build(BuildContext context) {
return const Diff(
ariaLabel: 'Sharp vs blurred photo comparison',
before: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
),
after: _DiffImg(
url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
),
)
.withStyle(
const CoreDiffStyle(
resizerColor: CoreColor.token(CoreColors.tertiary),
resizerWidth: CoreStrokeWidth.stroke4,
aspectRatio: 4 / 3,
),
)
.radius16;
}
}
class _DiffImg extends StatelessComponent {
const _DiffImg({required this.url});
final String url;
@override
Component build(BuildContext context) {
return img(
src: url,
alt: '',
// `not-content` opts out of the docs `prose` styling which
// otherwise injects `margin: 2em` and `height: auto` on every
// `<img>` inside the article body.
classes: 'not-content',
styles: const Styles(
raw: {
'display': 'block',
'width': '100%',
'height': '100%',
'object-fit': 'cover',
},
),
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
before |
Widget / Component |
필수 | 좌측 (before) 슬롯 |
after |
Widget / Component |
필수 | 우측 (after) 슬롯 |
onPositionChanged |
void Function(double)? |
null |
분할 위치 변경 콜백 |
interactive |
bool |
true |
드래그/키보드 인터랙션 활성화 |
ariaLabel |
String? |
null |
스크린 리더 라벨 |
diffStyle |
CoreDiffStyle? |
null |
chrome / dimensional 단일 진입점 (아래 표 참고) |
스타일 시스템 (Style System)#
모든 chrome / 치수 / layout override 는 diffStyle 슬롯 하나로 흐릅니다.
CoreDiffStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
borderRadius |
CoreBorderRadius? |
Container border radius override. |
resizerColor |
CoreColor? |
Resizer handle colour override. |
resizerWidth |
double? |
Resizer handle width override (logical px, pre-scaling). |
aspectRatio |
double? |
Container aspect ratio (width / height). Visual layout chrome — drives the box dimensions. |
initialPosition |
double? |
Initial split-bar position (0.0 — 1.0). Visual default chrome applied at first render; runtime drags overwrite it when
interactive: true
.
|
keyboardStep |
double? |
Position delta applied per arrow-key press (
[0, 1]
units) override. Falls back to [defaultKeyboardStep] when null.
|
Resolve chain#
CoreDiffStyle.defaultX (design system default)
→ CoreDiffTheme.style // 프로젝트 공통
→ widget.diffStyle // 인스턴스별
인터랙션#
-
드래그: 분할선(리사이저 핸들)을 좌우로 드래그해서 비율 조정. 커서는
resizeColumn으로 바뀝니다 -
키보드: 컴포넌트가 포커스된 상태에서 ←/→ 키로
diffStyle.keyboardStep(기본 5%) 만큼 이동 -
interactive: false면 드래그·키보드 모두 비활성. 프레임 본문을 탭해서 한쪽을 드러내는 동작은 없습니다 — 조작 지점은 분할선뿐입니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
항상 ariaLabel 지정
Diff(
ariaLabel: 'Filled vs surface text comparison',
before: before,
after: after,
)
기본값이 null이라 지정하지 않으면 인터랙티브 상태에서 값(aria-valuenow)만 있고 이름은 없는 슬라이더가 됩니다.
❌ Don't#
셋 이상의 콘텐츠를 순차적으로 넘겨보는 용도로 사용하지 않기
// ❌ Diff 하나로 여러 이미지를 순회하려는 시도
Diff(before: image1, after: image2) // image3, image4는 넣을 슬롯이 없음
Diff는 정확히 두 콘텐츠(before / after)만 좌우로 비교하는 컴포넌트입니다 — 여러 콘텐츠를 순차적으로 넘겨봐야 한다면 Carousel을 사용하세요.
접근성 (Accessibility)#
Diff 의 핵심 인터랙션은 0.0 ~ 1.0 사이의 분할 위치이고, interactive 일 때 그
값이 양 플랫폼 모두에서 슬라이더 값으로 노출됩니다. interactive: false
면 값
없는 그룹/컨테이너로 남습니다 — 움직이지 않는 슬라이더는 없는 컨트롤을 있다고
알리는 셈이기 때문입니다.
역할 (Semantics)#
interactive 여부로 갈립니다.
-
Flutter (
interactive: true):CoUISemantics(role: CoreSemanticRole.slider, value: …, minValue: '0', maxValue: '100', container: true). Flutter 에는 네이티브 slider role 이 없어Semantics(slider: true)플래그로 표현됩니다. -
Flutter (
interactive: false): 역할 없는 컨테이너 노드(container: true+label)이고 값 관련 필드는 전부 넘기지 않습니다. -
Web (
interactive: true):role="slider"+aria-valuemin="0"/aria-valuemax="100"/aria-valuenow/aria-valuetext. - Web (
interactive: false):role="group", 값 속성 없음.
값 단위는 분할 위치를 0 ~ 100 백분율로 반올림한 정수입니다 (aria-valuenow,
Flutter value 둘 다 같은 수). 읽히는 문장은 diffPositionLabel(percent)
로케일
멤버에서 오며 두 쪽을 함께 말합니다 — 예: 이전 40%, 이후 60%.
Web 은 호출자 attributes 를 먼저 병합하므로 하드코딩된 role /
aria-value* 가 호출자가 넘긴 같은 키를 덮습니다. aria-label 은
ariaLabel 이
null 이면 키 자체를 쓰지 않아 호출자 attributes 의 aria-label
이 남습니다.
키보드#
| 키 | 동작 |
|---|---|
← |
분할선을 diffStyle.keyboardStep(기본 0.05)만큼 왼쪽으로 |
→ |
분할선을 diffStyle.keyboardStep(기본 0.05)만큼 오른쪽으로 |
이 둘이 전부입니다 — Home / End / PageUp / PageDown 도, Shift 조합의 큰
단위 이동도 없습니다. interactive: false 면 Flutter 는 키 이벤트를 무시하고,
Web 은 키 핸들러 자체를 붙이지 않습니다.
포커스#
-
Flutter:
Focus(focusNode:)가 기본canRequestFocus로 만들어져interactive값과 무관하게 항상 탭으로 도달합니다.interactive: false일 때도 포커스는 받지만 모든 키를 무시합니다. -
Web:
interactive일 때만tabindex="0"이 붙습니다 — 비인터랙티브Diff는 포커스 대상이 아닙니다.
즉 같은 설정에서 탭 정지 개수가 플랫폼마다 다릅니다. 그리고 어느 플랫폼도 포커스 표시를 그리지 않습니다(포커스 링 없음). 트랩·복원·autofocus 도 없습니다.
스크린 리더#
인터랙티브 Diff 는 슬라이더로 읽히고 현재 분할 위치가 값으로 함께 읽힙니다
(이름은 ariaLabel 을 넘겼을 때만). 비인터랙티브 Diff 는 Web 에서 "group",
Flutter 에서 역할 없는 컨테이너로 읽히고 값은 없습니다.
값 갱신은 포커스된 슬라이더 노드의 값 속성이 매 변경마다 다시 그려지는 것에
의존합니다 — 별도 live region 이나 명시적 announce 호출은 없습니다. 그래서 포커스가
Diff 에 없는 상태(예: 다른 요소에 포커스를 둔 채 드래그)에서 일어난 변경은
안내되지 않습니다.
알려진 제약#
-
끝점·큰 단위 이동이 없습니다 —
Home/End/PageUp/PageDown이 없어 슬라이더 관례가 기대하는 "양 끝으로 즉시" 이동을 키보드로 할 수 없습니다. -
Flutter 에 증가/감소 semantics action 이 없습니다 —
onIncrease/onDecrease를 노출하지 않으므로 터치 스크린 리더의 슬라이더 조정 제스처로는 값이 바뀌지 않습니다. 실제 이동 경로는 물리 방향키와 드래그뿐입니다. - 포커스 링이 없어 키보드 사용자가
Diff에 포커스가 왔는지 볼 수 없습니다. -
interactive: false의 포커스 동작이 플랫폼마다 다릅니다 — Flutter 는 여전히 포커스를 받고 Web 은 받지 않습니다. -
기본 상태에서는 이름이 없습니다.
ariaLabel이 유일한 이름 경로이고 기본값이null이라, 그대로 쓰면 값만 있고 이름이 없는 슬라이더가 됩니다 — 예제처럼ariaLabel을 항상 넘기는 것을 권장합니다. - 드래그 리사이저 핸들 자체는 역할도 이름도 없는 요소입니다. 값을 나르는 것은 바깥 컨테이너 노드이고, 핸들은 포인터 조작 지점일 뿐입니다.
-
interactive는 키 입력·(Web 의)tabindex·역할 선택만 제어할 뿐aria-disabled/ readonly 로 노출되지 않습니다 — 비인터랙티브Diff는 "비활성 슬라이더"가 아니라 그냥 그룹/컨테이너가 됩니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
관련 컴포넌트 (Related Components)#
- CodeDiff: 라인 단위 코드 diff