RefreshTrigger | CoUI
LogoCoUI

RefreshTrigger

스크롤을 당겨서 콘텐츠를 새로고침하는 Pull-to-Refresh 컴포넌트

RefreshTrigger#

스크롤 가능한 콘텐츠를 아래로 당겨서 새로고침을 트리거하는 컴포넌트입니다. iOS와 Android 스타일의 당겨서 새로고침 패턴을 구현합니다.

Live Preview#

사용 시기 (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)#

  • idlepulling: 최상단에서 아래로 드래그 시작
  • pulling: minExtent 초과 시 라벨이 releaseText로 전환
  • pullingrefreshing: 드래그 해제 후 onRefresh 실행
  • refreshingcompletedidle: 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)#

항목FlutterWeb
클래스명RefreshTriggerRefreshTrigger
stage 구동제스처로 자동 + stage 명시(non-idle) 시 controlled동일
제스처 감지 ScrollNotification overscroll/pull pointer 이벤트(터치/마우스) + 스크롤 경계 감지
인디케이터surface 카드 + 화살표/스피너/체크마크동일 (체크마크는 정적 글리프)
  • Loading: 페이지 또는 섹션 로딩 중 스피너/스켈레톤 표시
  • Progress: 데이터 로딩 진행률을 퍼센트로 표시

조합 예제#

// 피드 화면에 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]),
    ),
  ),
)