RefreshTrigger#
스크롤 가능한 콘텐츠를 아래로 당겨서 새로고침을 트리거하는 컴포넌트입니다. iOS와 Android 스타일의 당겨서 새로고침 패턴을 구현합니다.
Live Preview#
class RefreshTriggerDefaultExample extends StatefulComponent {
const RefreshTriggerDefaultExample({super.key});
@override
State<RefreshTriggerDefaultExample> createState() =>
_RefreshTriggerDefaultExampleState();
}
class _RefreshTriggerDefaultExampleState
extends State<RefreshTriggerDefaultExample> {
// A GlobalStateKey lets us access the RefreshTrigger's state so we
// can trigger a programmatic refresh (via the button) in addition to
// the pull gesture.
final _refreshTriggerKey = GlobalStateKey<RefreshTriggerState>();
@override
Component build(BuildContext context) {
return OutlinedContainer(
classes: 'w-full',
child: RefreshTrigger(
key: _refreshTriggerKey,
css: const Styles(raw: {'height': 'var(--coui-space-256)'}),
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: div(
[
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () => _refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
classes: 'flex w-full flex-col items-center',
styles: const Styles(
raw: {
'height': 'calc(var(--coui-space-256) * 2)',
'padding-top': 'var(--coui-space-32)',
},
),
),
),
);
}
}
class RefreshTriggerDefaultExample extends StatefulWidget {
const RefreshTriggerDefaultExample({super.key});
@override
State<RefreshTriggerDefaultExample> createState() =>
_RefreshTriggerDefaultExampleState();
}
class _RefreshTriggerDefaultExampleState
extends State<RefreshTriggerDefaultExample> {
// A GlobalKey lets us access the RefreshTrigger's state so we can
// trigger a programmatic refresh (via the button) in addition to the
// pull gesture.
final _refreshTriggerKey = GlobalKey<RefreshTriggerState>();
@override
Widget build(BuildContext context) {
return OutlinedContainer(
child: SizedBox(
height: CoreSpace.space256,
child: RefreshTrigger(
key: _refreshTriggerKey,
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: ScrollArea(
child: Container(
height: CoreSpace.space256 * 2,
padding: const EdgeInsets.only(top: CoreSpace.space32),
alignment: Alignment.topCenter,
child: Column(
mainAxisSize: .min,
children: [
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () =>
_refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
),
),
),
),
),
);
}
}
class RefreshTriggerChainExample extends StatefulComponent {
const RefreshTriggerChainExample({super.key});
@override
State<RefreshTriggerChainExample> createState() => _RefreshTriggerChainExampleState();
}
class _RefreshTriggerChainExampleState extends State<RefreshTriggerChainExample> {
// A GlobalStateKey lets us access the RefreshTrigger's state so we
// can trigger a programmatic refresh (via the button) in addition to
// the pull gesture.
final _refreshTriggerKey = GlobalStateKey<RefreshTriggerState>();
@override
Component build(BuildContext context) {
return OutlinedContainer(
classes: 'w-full',
child:
RefreshTrigger(
key: _refreshTriggerKey,
css: const Styles(raw: {'height': 'var(--coui-space-256)'}),
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: div(
[
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () => _refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
classes: 'flex w-full flex-col items-center',
styles: const Styles(
raw: {
'height': 'calc(var(--coui-space-256) * 2)',
'padding-top': 'var(--coui-space-32)',
},
),
),
).withStyle(
const CoreRefreshTriggerStyle(
indicatorBackgroundColor: CoreColor.token(CoreColors.secondaryContainer),
indicatorBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
indicatorPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
indicatorGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
),
);
}
}
class RefreshTriggerChainExample extends StatefulWidget {
const RefreshTriggerChainExample({super.key});
@override
State<RefreshTriggerChainExample> createState() => _RefreshTriggerChainExampleState();
}
class _RefreshTriggerChainExampleState extends State<RefreshTriggerChainExample> {
// A GlobalKey lets us access the RefreshTrigger's state so we can
// trigger a programmatic refresh (via the button) in addition to the
// pull gesture.
final _refreshTriggerKey = GlobalKey<RefreshTriggerState>();
@override
Widget build(BuildContext context) {
return OutlinedContainer(
child: SizedBox(
height: CoreSpace.space256,
child:
RefreshTrigger(
key: _refreshTriggerKey,
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: ScrollArea(
child: Container(
height: CoreSpace.space256 * 2,
padding: const EdgeInsets.only(top: CoreSpace.space32),
alignment: Alignment.topCenter,
child: Column(
mainAxisSize: .min,
children: [
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () => _refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
),
),
),
).withStyle(
const CoreRefreshTriggerStyle(
indicatorBackgroundColor: CoreColor.token(CoreColors.secondaryContainer),
indicatorBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
indicatorPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
indicatorGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 소셜 피드, 뉴스 목록처럼 최신 데이터를 수동으로 불러와야 할 때
- 모바일 앱에서 네이티브 pull-to-refresh 패턴을 구현할 때
- 스크롤 가능한 영역의 내용을 사용자가 직접 갱신할 수 있어야 할 때
대신 다른 컴포넌트를 사용하세요:
Loading: 자동 새로고침 또는 페이지 로딩 중 표시에Progress: 작업 진행 상태를 퍼센트로 표시할 때
기본 사용법 (Basic Usage)#
Flutter와 Web 모두 동일한 RefreshTrigger API를 사용합니다.
// 기본 당겨서 새로고침 — 스크롤 제스처로 stage가 자동 구동됩니다.
RefreshTrigger(
onRefresh: () async {
await fetchLatestData();
},
child: ListView(
children: const [
Text('Inbox'),
Text('Drafts'),
Text('Sent'),
],
),
)
// 당김 거리 / 인디케이터 스타일 커스터마이징
RefreshTrigger(
onRefresh: () async => fetchLatestData(),
minExtent: 60,
maxExtent: 200,
refreshTriggerStyle: const CoreRefreshTriggerStyle(
refreshingTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
child: ListView(children: items),
)
// 기본 당겨서 새로고침 — 포인터(터치/마우스) 제스처로 stage가
// 자동 구동됩니다. 스크롤 경계에서 당기면 인디케이터가 나타납니다.
RefreshTrigger(
onRefresh: () async {
await fetchLatestData();
},
child: div(
const [
Text('Inbox'),
Text('Drafts'),
Text('Sent'),
],
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
),
)
// 인디케이터 스타일 커스터마이징 — 외부 상태로 구동할 때만
// stage 를 직접 주입합니다 (non-idle 이면 controlled).
RefreshTrigger(
onRefresh: () async => fetchLatestData(),
refreshTriggerStyle: const CoreRefreshTriggerStyle(
refreshingTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
child: contentList,
)
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
필수 | 새로고침 대상 스크롤 가능한 콘텐츠 |
stage |
CoreRefreshStage |
idle |
인디케이터 lifecycle 단계 (controlled) |
direction |
CoreRefreshDirection |
vertical |
당김 제스처 축 |
reverse |
bool |
false |
반대쪽 가장자리(하단/우측)에서 시작 |
pullText |
String? |
null (활성 로케일 문구) |
idle 단계 라벨 |
releaseText |
String? |
null (활성 로케일 문구) |
임계값 초과 pulling 단계 라벨 |
refreshingText |
String? |
null (활성 로케일 문구) |
refreshing 단계 라벨 |
completedText |
String? |
null (활성 로케일 문구) |
completed 단계 라벨 |
minExtent |
double |
CoreRefreshTriggerStyle.defaultMinExtent (75) |
새로고침 트리거 최소 당김 거리(px) |
maxExtent |
double |
CoreRefreshTriggerStyle.defaultMaxExtent (150) |
최대 당김 거리(px) |
onRefresh |
Future<void> Function()? |
null |
새로고침 트리거 시 호출되는 비동기 핸들러 |
refreshTriggerStyle |
CoreRefreshTriggerStyle? |
null |
인디케이터 chrome / 치수 / 애니메이션 오버라이드 |
라벨 4종은 nullable 이라 값을 넘기지 않으면 렌더 시점에 활성 로케일의
CoUILocalizations / CouiLocalizations 문구로 해석됩니다.
스타일 시스템 — refreshTriggerStyle#
인디케이터의 모든 chrome 은 단일 refreshTriggerStyle
(CoreRefreshTriggerStyle) 슬롯으로 흐릅니다.
CoreRefreshTriggerStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
indicatorBackgroundColor |
CoreColor? |
Indicator surface fill colour override. |
indicatorBorderRadius |
CoreBorderRadius? |
Indicator corner radius override. |
indicatorPadding |
CoreEdgeInsets? |
Indicator content padding override for the resting state. |
pullingPadding |
CoreEdgeInsets? |
Indicator content padding override for the compact pulling state. |
indicatorGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the gap between indicator elements (icon and label). Forwarded to the
Gap
siblings rendered between the indicator row children. Merged on top of [defaultIndicatorGapStyle].
|
idleTextStyle |
CoreTextStyle? |
Per-stage text style override for the
idle
/
pulling
stages. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
idleTextColor
field removed). Defaults to [defaultIdleTextStyle].
|
refreshingTextStyle |
CoreTextStyle? |
Per-stage text style override for the
refreshing
stage. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
refreshingTextColor
field removed). Defaults to [defaultRefreshingTextStyle].
|
completedTextStyle |
CoreTextStyle? |
Per-stage text style override for the
completed
stage. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
completedTextColor
field removed). Defaults to [defaultCompletedTextStyle].
|
animationDuration |
Duration? |
Slide / cross-fade animation duration override. |
labelStyle |
CoreTextStyle? |
Indicator label text style override.
No
defaultLabelStyle
, deliberately — this field's baseline is stage-keyed and already exists under three other names.
It is a cross-stage
overlay
, not a stage's own style: Flutter merges it last onto each per-stage slot ([defaultIdleTextStyle] / [defaultRefreshingTextStyle] / [defaultCompletedTextStyle], each already a
default*
), and Web passes the active stage's slot as
emitTypography
's
default
argument with this field as its
value
. Because the overlay is merged last, any non-null constant here wins over all three stage defaults at once — their
labelLarge
role would become unreachable on both platforms while the stage colours (forced separately) kept working, so the regression would read as "the label is the wrong size" with nothing nearby to explain it.
|
Resolve chain#
design system default (CoreRefreshTriggerStyle.defaultX)
→ CoreRefreshTriggerTheme.style // 프로젝트 공통
→ widget.refreshTriggerStyle // 인스턴스별
빠른 오버라이드 (Chain)#
이미 만든 RefreshTrigger 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class RefreshTriggerChainExample extends StatefulWidget {
const RefreshTriggerChainExample({super.key});
@override
State<RefreshTriggerChainExample> createState() => _RefreshTriggerChainExampleState();
}
class _RefreshTriggerChainExampleState extends State<RefreshTriggerChainExample> {
// A GlobalKey lets us access the RefreshTrigger's state so we can
// trigger a programmatic refresh (via the button) in addition to the
// pull gesture.
final _refreshTriggerKey = GlobalKey<RefreshTriggerState>();
@override
Widget build(BuildContext context) {
return OutlinedContainer(
child: SizedBox(
height: CoreSpace.space256,
child:
RefreshTrigger(
key: _refreshTriggerKey,
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: ScrollArea(
child: Container(
height: CoreSpace.space256 * 2,
padding: const EdgeInsets.only(top: CoreSpace.space32),
alignment: Alignment.topCenter,
child: Column(
mainAxisSize: .min,
children: [
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () => _refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
),
),
),
).withStyle(
const CoreRefreshTriggerStyle(
indicatorBackgroundColor: CoreColor.token(CoreColors.secondaryContainer),
indicatorBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
indicatorPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
indicatorGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
),
),
);
}
}
class RefreshTriggerChainExample extends StatefulComponent {
const RefreshTriggerChainExample({super.key});
@override
State<RefreshTriggerChainExample> createState() => _RefreshTriggerChainExampleState();
}
class _RefreshTriggerChainExampleState extends State<RefreshTriggerChainExample> {
// A GlobalStateKey lets us access the RefreshTrigger's state so we
// can trigger a programmatic refresh (via the button) in addition to
// the pull gesture.
final _refreshTriggerKey = GlobalStateKey<RefreshTriggerState>();
@override
Component build(BuildContext context) {
return OutlinedContainer(
classes: 'w-full',
child:
RefreshTrigger(
key: _refreshTriggerKey,
css: const Styles(raw: {'height': 'var(--coui-space-256)'}),
onRefresh: () async {
await Future<void>.delayed(const Duration(seconds: 1));
},
child: div(
[
const Text('Pull Me'),
const Gap.space16(),
Button(
onPressed: () => _refreshTriggerKey.currentState?.refresh(),
variant: .primary,
child: const Text('Refresh'),
),
],
classes: 'flex w-full flex-col items-center',
styles: const Styles(
raw: {
'height': 'calc(var(--coui-space-256) * 2)',
'padding-top': 'var(--coui-space-32)',
},
),
),
).withStyle(
const CoreRefreshTriggerStyle(
indicatorBackgroundColor: CoreColor.token(CoreColors.secondaryContainer),
indicatorBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
indicatorPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
indicatorGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
),
);
}
}
변형 (Variants)#
기본 (Default)#
디자인 시스템 기본 인디케이터를 사용합니다.
RefreshTrigger(
onRefresh: () async => fetchLatestData(),
child: ListView(children: items),
)
커스텀 인디케이터 스타일#
refreshTriggerStyle로 인디케이터 배경 / 텍스트 색상을 변경합니다.
RefreshTrigger(
onRefresh: () async => fetchLatestData(),
refreshTriggerStyle: const CoreRefreshTriggerStyle(
indicatorBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
refreshingTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
child: ListView(children: items),
)
넓은 당김 거리#
인디케이터가 더 많이 당겨져야 새로고침이 트리거됩니다.
RefreshTrigger(
onRefresh: () async => fetchLatestData(),
minExtent: 120,
child: ListView(children: items),
)
동작 스펙 (Behavior)#
인터랙션#
- 당기기: 스크롤 최상단에서 아래로 당기면 인디케이터 표시
- 충분히 당기기:
minExtent이상 당기면 새로고침 트리거 표시 - 해제: 당기기를 놓으면
onRefresh()호출, 완료까지 인디케이터 표시 - 중단:
minExtent미만에서 놓으면 복귀
상태 전환 (CoreRefreshStage)#
idle→pulling: 최상단에서 아래로 드래그 시작pulling:minExtent초과 시 라벨이releaseText로 전환pulling→refreshing: 드래그 해제 후onRefresh실행refreshing→completed→idle: Future 완료 후 완료 표시를 거쳐 복귀
애니메이션#
- 인디케이터 등장: 드래그 거리에 비례한 회전 애니메이션
- 새로고침 중: 무한 회전 스피너
- 복귀: 스프링 효과 300ms
사용 가이드라인 (Usage Guidelines)#
✅ Do#
onRefresh를 async/await로 올바르게 구현
Future<void> handleRefresh() async {
final newData = await fetchLatestData();
setState(() {
_items = newData;
});
// Future가 완료되면 인디케이터 자동 숨김
}
RefreshTrigger(
onRefresh: handleRefresh,
child: itemList,
)
onRefresh가 올바르게 완료되어야 인디케이터가 자동으로 숨겨진다.
❌ Don't#
스크롤할 수 없는 위젯에 RefreshTrigger 사용
// ❌ 스크롤 불가능한 위젯에 사용
RefreshTrigger(
onRefresh: handleRefresh,
child: Container(
child: Text('스크롤 없는 콘텐츠'),
),
)
스크롤 가능한 위젯이 없으면 pull 제스처가 작동하지 않는다.
✅ Do#
브랜드 색상으로 인디케이터 색상 지정
RefreshTrigger(
onRefresh: handleRefresh,
refreshTriggerStyle: const CoreRefreshTriggerStyle(
refreshingTextStyle: CoreTextStyle.token(
CoreTextStyles.labelLarge,
color: CoreColor.token(CoreColors.primary),
),
),
child: feedList,
)
브랜드 색상의 인디케이터는 앱의 일관된 시각 언어를 유지한다.
❌ Don't#
새로고침 중에도 UI가 완전히 차단되지 않도록 주의
// ❌ 새로고침 중 전체 화면을 가리는 로딩 오버레이
RefreshTrigger(
onRefresh: () async {
showFullScreenLoader(); // 중복 로딩 표시
await fetchData();
hideFullScreenLoader();
},
child: feedList,
)
RefreshTrigger 자체가 로딩 인디케이터를 표시하므로 별도 전체 화면 로딩은 중복이다.
✅ Do#
새로고침 완료 후 명확한 피드백을 제공하세요.
RefreshTrigger(
onRefresh: () async {
await dataRepository.refresh();
// 새로고침 완료 후 스낵바 또는 업데이트 시간 표시
showRefreshCompleted();
},
child: ContentList(),
)
새로고침이 완료되면 사용자가 데이터가 업데이트되었음을 인식할 수 있도록 명확한 피드백을 제공하세요.
❌ Don't#
새로고침 중 추가 인터랙션을 허용하지 마세요.
// ❌ 새로고침 중 버튼 클릭 가능
RefreshTrigger(
onRefresh: handleRefresh,
child: Column(
children: [
ContentList(),
Button(
onPressed: handleLoadMore, // 새로고침 중에도 활성화됨
child: Text('더 보기'),
),
],
),
)
새로고침 중에는 다른 데이터 요청을 막아야 데이터 충돌이나 중복 요청을 방지할 수 있습니다.
접근성 (Accessibility)#
키보드 인터랙션#
| 키 | 동작 |
|---|---|
| 해당 없음 | 제스처 기반 컴포넌트; 키보드 대안은 새로고침 버튼으로 제공 권장 |
스크린 리더#
- Flutter: 새로고침 상태 변경 시
SemanticsService.announce("새로고침 완료")호출 - Web:
aria-live="polite"영역에 새로고침 상태 메시지 업데이트
터치 타겟#
- 제스처 기반 컴포넌트로 별도 터치 타겟 없음
- 접근성을 위해 화면 상단에 새로고침 버튼을 추가로 제공 권장
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | RefreshTrigger | RefreshTrigger |
| stage 구동 | 제스처로 자동 + stage 명시(non-idle) 시 controlled | 동일 |
| 제스처 감지 | ScrollNotification overscroll/pull |
pointer 이벤트(터치/마우스) + 스크롤 경계 감지 |
| 인디케이터 | surface 카드 + 화살표/스피너/체크마크 | 동일 (체크마크는 정적 글리프) |
관련 컴포넌트 (Related Components)#
조합 예제#
// 피드 화면에 RefreshTrigger 적용
Scaffold(
headers: [
AppBar(
title: Text('피드').titleMedium.semiBold,
trailing: [
// 키보드 접근성을 위한 새로고침 버튼
Tooltip(
message: '새로고침',
child: Button(
variant: CoreButtonVariant.plain,
onPressed: handleManualRefresh,
child: const Icon(LucideIcons.refreshCw),
),
),
],
),
],
child: RefreshTrigger(
onRefresh: handleRefresh,
child: ListView.builder(
itemCount: feedItems.length,
itemBuilder: (context, index) => FeedCard(item: feedItems[index]),
),
),
)