ScrollArea | CoUI
LogoCoUI

ScrollArea

์ฝ˜ํ…์ธ ๋ฅผ ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•œ ๋ทฐํฌํŠธ๋กœ ๊ฐ์‹ธ๊ณ  ๋””์ž์ธ ์‹œ์Šคํ…œ ์Šคํฌ๋กค๋ฐ”๋ฅผ ์ž…ํžˆ๋Š” ๋ž˜ํผ

ScrollArea#

์•„๋ฌด child ๋‚˜ ์Šคํฌ๋กค ๊ฐ€๋Šฅํ•œ ๋ทฐํฌํŠธ๋กœ ๊ฐ์‹ธ๊ณ  ์Šฌ๋ฆผํ•œ ๋””์ž์ธ ์‹œ์Šคํ…œ ์Šคํฌ๋กค๋ฐ”๋ฅผ ์ž…ํžˆ๋Š” ๋ž˜ํผ์ž…๋‹ˆ๋‹ค. ์—ฃ์ง€๋ฅผ ๊ทธ๋ผ๋ฐ์ด์…˜์œผ๋กœ ๊ฐ€๋ฆฌ๋Š” FadeScroll ๊ณผ ๋‹ฌ๋ฆฌ ์ฝ˜ํ…์ธ ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณด์—ฌ์ฃผ๊ณ , ์˜ค๋ฒ„ํ”Œ๋กœ๋Š” ์Šคํฌ๋กค๋ฐ”๋กœ๋งŒ ์•Œ๋ฆฝ๋‹ˆ๋‹ค. Flutter / Web ๋™์ผ API ์ž…๋‹ˆ๋‹ค.

Live Preview#

์‚ฌ์šฉ๋ฒ•#

// Flutter / Web ๋™์ผ
ScrollArea(
  scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
  child: Column(
    children: [for (var i = 0; i < 20; i += 1) Text('Item $i')],
  ),
)

๋น ๋ฅธ ์˜ค๋ฒ„๋ผ์ด๋“œ (Chain)#

์ด๋ฏธ ๋งŒ๋“  ScrollArea ์ธ์Šคํ„ด์Šค์— ์Šคํƒ€์ผ์„ ๋น ๋ฅด๊ฒŒ ๋ง๋ถ™์ด๊ณ  ์‹ถ๋‹ค๋ฉด withStyle ์ฒด์ธ์„ ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ์ž์˜ style ์Šฌ๋กฏ ์ธ์ž์™€ ๋™์ผํ•˜๊ฒŒ ๋™์ž‘ํ•˜์ง€๋งŒ, ์ด๋ฏธ ๊ตฌ์„ฑ๋œ ์œ„์ ฏ ์œ„์—์„œ ๋ฐ”๋กœ ์ด์–ด ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

class ScrollAreaChainExample extends StatelessWidget {
  const ScrollAreaChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 260,
      child:
          ScrollArea(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              spacing: CoreSpace.space8,
              children: [
                for (var i = 1; i <= 20; i += 1) Text('์Šคํฌ๋กค ํ•ญ๋ชฉ $i').bodyMedium,
              ],
            ),
          ).withStyle(
            const CoreScrollAreaStyle(
              height: CoreSpace.space192,
              maxHeight: CoreSpace.space192,
              scrollbarStyle: CoreScrollbarStyle(
                thumbColor: CoreColor.token(CoreColors.primary),
                thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
                thickness: CoreStrokeWidth.stroke8,
              ),
            ),
          ),
    );
  }
}
class ScrollAreaChainExample extends StatelessComponent {
  const ScrollAreaChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      styles: Styles(raw: {'width': '260px'}),
      [
        ScrollArea(
          child: div(
            classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
            [
              for (var i = 1; i <= 20; i += 1) Text('์Šคํฌ๋กค ํ•ญ๋ชฉ $i').bodyMedium,
            ],
          ),
        ).withStyle(
          const CoreScrollAreaStyle(
            height: CoreSpace.space192,
            maxHeight: CoreSpace.space192,
            scrollbarStyle: CoreScrollbarStyle(
              thumbColor: CoreColor.token(CoreColors.primary),
              thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
              thickness: CoreStrokeWidth.stroke8,
            ),
          ),
        ),
      ],
    );
  }
}

Props#

ํŒŒ๋ผ๋ฏธํ„ฐํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
child Widget / Component required ๋ทฐํฌํŠธ ์•ˆ์—์„œ ์Šคํฌ๋กค๋˜๋Š” ์ฝ˜ํ…์ธ 
direction CoreScrollAreaDirection vertical ์Šคํฌ๋กค ์ถ• (vertical / horizontal)
showScrollbar bool true ๋””์ž์ธ ์‹œ์Šคํ…œ ์Šคํฌ๋กค๋ฐ” ํ‘œ์‹œ ์—ฌ๋ถ€ (false ๋ฉด ์Šคํฌ๋กค์€ ๋˜๋˜ ๋ฐ”๋Š” ์ˆจ๊น€)
scrollAreaStyle CoreScrollAreaStyle? null chrome ๋‹จ์ผ ์ง„์ž…์  (์•„๋ž˜ ํ•„๋“œ ํ‘œ)
controller ScrollController? null (Flutter ์ „์šฉ ๋Ÿฐํƒ€์ž„ ์ธํ”„๋ผ) ์™ธ๋ถ€์—์„œ ์Šคํฌ๋กค ์œ„์น˜๋ฅผ ๊ตฌ๋™. Web ์€ ๋ธŒ๋ผ์šฐ์ € native overflow ๋กœ ๋™์ž‘

CoreScrollAreaStyle ํ•„๋“œ#

ํ•„๋“œํƒ€์ž…์„ค๋ช…
height double? Fixed viewport height in logical pixels. When null the scroll area adopts its parent's height (or its child's intrinsic height when no parent constraint exists). No default, and must not have one. Both fields together are a switch, not a measurement: Flutter builds a ConstrainedBox only under if (resolved.height != null || resolved.maxHeight != null) and returns the unwrapped scroll view otherwise, and Web writes the height declaration only inside if (height != null) . A constant would make that branch permanently taken, so every scroll area in the tree would be clamped to one height regardless of the space its parent gave it โ€” and a scroll area that ignores its parent is the one thing this component must not do.
maxHeight double? Maximum viewport height in logical pixels. When set, the scroll area sizes to the child's intrinsic height up to this maximum and becomes scrollable when overflowed. Mutually composes with [height] (host platforms apply both when both are set). No default, and must not have one โ€” it is the other half of the switch described on [height], and a constant would additionally introduce a scroll boundary where the caller asked for none.
scrollbarStyle CoreScrollbarStyle? Nested chrome for the composed Scrollbar (thumb colour / thickness / corner radius / minimum length / track colour). When null falls back to [defaultScrollbarStyle]; partial overrides merge on top of it.

์‚ฌ์šฉ ๊ฐ€์ด๋“œ๋ผ์ธ (Usage Guidelines)#

โœ… Do#

๊ฐ€๋ณ€ ๊ธธ์ด ์ฝ˜ํ…์ธ ์—๋Š” maxHeight ๋ฅผ ๋ช…์‹œ

ScrollArea(
  scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 240),
  child: Column(children: [for (final item in items) Text(item)]),
)

maxHeight ๋ฅผ ์ฃผ์ง€ ์•Š์œผ๋ฉด ๋ถ€๋ชจ ์ œ์•ฝ์ด ์—†๋Š” ํ•œ ์ž์‹์˜ intrinsic ๋†’์ด๋ฅผ ๊ทธ๋Œ€๋กœ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค โ€” ์ด ๊ฒฝ์šฐ ๋„˜์นจ ์ž์ฒด๊ฐ€ ์ƒ๊ธฐ์ง€ ์•Š์•„ ์Šคํฌ๋กค๋ฐ”๋„ ๋“ฑ์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.


โŒ Don't#

๋Œ€์ฒด ์–ดํฌ๋˜์Šค ์—†์ด showScrollbar: false ์‚ฌ์šฉ ๊ธˆ์ง€

// โŒ ๋„˜์นจ์„ ์•Œ๋ฆด ๋‹ค๋ฅธ ์ˆ˜๋‹จ ์—†์ด ์Šคํฌ๋กค๋ฐ”๋งŒ ์ˆจ๊น€
ScrollArea(
  showScrollbar: false,
  scrollAreaStyle: const CoreScrollAreaStyle(maxHeight: 180),
  child: longContent,
)

์ฝ˜ํ…์ธ ๊ฐ€ ๋” ์žˆ๋‹ค๋Š” ์‚ฌ์‹ค์„ ์•Œ๋ฆฌ๋Š” ์œ ์ผํ•œ ์‹œ๊ฐ ๋‹จ์„œ๊ฐ€ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค โ€” ๊ทธ๋ผ๋ฐ์ด์…˜์ด๋‚˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์ฒ˜๋Ÿผ ์ด๋ฅผ ๋Œ€์ฒดํ•  ์–ดํฌ๋˜์Šค๊ฐ€ ์—†์œผ๋ฉด ์‚ฌ์šฉ์ž๋Š” ์Šคํฌ๋กค์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ๊ฒƒ ์ž์ฒด๋ฅผ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ ‘๊ทผ์„ฑ (Accessibility)#

์—ญํ•  (Semantics)#

์–‘ ํ”Œ๋žซํผ ๋ชจ๋‘ ์—ญํ• ์„ ๋‚ด๋ณด๋‚ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. Flutter ๋Š” SingleChildScrollView ์— Scrollbar ๋ฅผ ํ•ฉ์„ฑํ•˜๊ณ  ConstrainedBox ๋กœ ๊ฐ์Œ€ ๋ฟ Semantics ๋…ธ๋“œ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์œผ๋ฉฐ, Web ์€ ํ•ฉ์„ฑ๋œ Scrollbar ๊ฐ€ ๊ทธ๋ฆฌ๋Š” overflow-* ํด๋ž˜์Šค๊ฐ€ ๋ถ™์€ <div> ํ•˜๋‚˜๋งŒ ๋‚ด๋ณด๋‚ด๊ณ  role / aria-* ๋Š” ํ•˜๋‚˜๋„ ๋ถ™์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค(ํ˜ธ์ถœ์ž๊ฐ€ ๋„˜๊ธด attributes ๋Š” ๊ทธ๋Œ€๋กœ ํ†ต๊ณผ).

์ฆ‰ "์—ฌ๊ธฐ๊ฐ€ ์Šคํฌ๋กค๋˜๋Š” ์˜์—ญ"์ด๋ผ๋Š” ์‚ฌ์‹ค ์ž์ฒด๊ฐ€ ๋ณด์กฐ๊ธฐ์ˆ ์— ์ „๋‹ฌ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์˜์—ญ ์ด๋ฆ„์ด ํ•„์š”ํ•˜๋ฉด ์†Œ๋น„์ž๊ฐ€ ์ง์ ‘ ๋ถ™์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค โ€” Web ์€ attributes ๋กœ role="region" ยท aria-label ์„, Flutter ๋Š” ๋ฐ”๊นฅ์— ์ž๊ธฐ Semantics ๋ฅผ ๊ฐ์‹ธ๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.

ํ‚ค๋ณด๋“œ#

์ด ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํ•ด์„ํ•˜๋Š” ํ‚ค๋Š” ์—†์Šต๋‹ˆ๋‹ค. Flutter ์—์„œ ๋ฐฉํ–ฅํ‚ค ยท Page ยท Home ยท End ๋กœ ์Šคํฌ๋กค๋˜๋Š” ๊ฒƒ์€ SingleChildScrollView ์•ˆ์˜ ํ”„๋ ˆ์ž„์›Œํฌ Scrollable ์ด ์ฃผ๋Š” ๋™์ž‘์ด๊ณ , Web ์—์„œ๋Š” ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฃผ๋Š” ๋™์ž‘์ž…๋‹ˆ๋‹ค โ€” ๊ทธ๋ฆฌ๊ณ  Web ์ชฝ์€ ์Šคํฌ๋กค ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ํฌ์ปค์Šค๋ฅผ ๋ฐ›์•˜์„ ๋•Œ๋งŒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค(์•„๋ž˜ ์ œ์•ฝ ์ฐธ์กฐ). Web ์˜ onKeyDown / onKeyUp ์€ ํ˜ธ์ถœ์ž๊ฐ€ ๋„˜๊ธด ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋ฃจํŠธ์— ์—ฐ๊ฒฐํ•˜๋Š” ํ†ต๊ณผ ์Šฌ๋กฏ์ผ ๋ฟ, ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ฑ„์šฐ๋Š” ๊ฐ’์ด ์•„๋‹™๋‹ˆ๋‹ค.

ํฌ์ปค์Šค#

ํฌ์ปค์Šค๋ฅผ ๋ฐ›์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–‘ ํ”Œ๋žซํผ ๋ชจ๋‘ FocusNode / Focus / tabindex ๊ฐ€ ์—†๊ณ  ํฌ์ปค์Šค ๋ง๋„ ๊ทธ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํƒญ ์ˆœ์„œ์— ๋“ฑ์žฅํ•˜์ง€ ์•Š์œผ๋ฉฐ, ํฌ์ปค์Šค๋ฅผ ๊ฐ€๋‘๊ฑฐ๋‚˜ ๋˜๋Œ๋ฆฌ์ง€๋„ ์•Š์Šต๋‹ˆ๋‹ค โ€” ์ฝ˜ํ…์ธ  ์•ˆ์˜ ํฌ์ปค์Šค ๊ฐ€๋Šฅํ•œ ์š”์†Œ๋Š” ๊ฐ์ž์˜ ๋™์ž‘์„ ๊ทธ๋Œ€๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.

์Šคํฌ๋ฆฐ ๋ฆฌ๋”#

์˜์—ญ ์ž์ฒด์—๋Š” ์ด๋ฆ„์ด ์—†์Šต๋‹ˆ๋‹ค โ€” Flutter ๋Š” ํ”„๋ ˆ์ž„์›Œํฌ Scrollable ์ด ๊ธฐ์—ฌํ•˜๋Š” ์Šคํฌ๋กค ์•ก์…˜์ด, Web ์€ ์ด๋ฆ„ ์—†๋Š” <div> ์•ˆ์ชฝ ์ฝ˜ํ…์ธ ๊ฐ€ ์ฝํž™๋‹ˆ๋‹ค.

showScrollbar ๊ฐ€ ์ผœ์ ธ ์žˆ์œผ๋ฉด ํ•ฉ์„ฑ๋œ Scrollbar ๊ฐ€ ์Šคํฌ๋กค ์œ„์น˜๋ฅผ 0โ€“100 ํผ์„ผํŠธ๋กœ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค (Web role="scrollbar" + aria-valuenow, Flutter slider Semantics). ๋‚จ์€ ๋ถ„๋Ÿ‰ ์ž์ฒด๋Š” ์–ด๋А ์ชฝ์—์„œ๋„ ์•ˆ๋‚ด๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์•Œ๋ ค์ง„ ์ œ์•ฝ#

  • Web ์Šคํฌ๋กค ์ปจํ…Œ์ด๋„ˆ์— tabindex ๊ฐ€ ์—†์–ด ํ‚ค๋ณด๋“œ๋งŒ ์“ฐ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ด ์˜์—ญ์„ ์Šคํฌ๋กคํ•  ์ˆ˜ ์žˆ๋Š”์ง€๊ฐ€ ์ „์ ์œผ๋กœ ๋ธŒ๋ผ์šฐ์ € ๋™์ž‘์— ๋‹ฌ๋ ค ์žˆ์Šต๋‹ˆ๋‹ค. ํ‚ค๋ณด๋“œ๋กœ ํ™•์‹คํžˆ ์Šคํฌ๋กค์‹œํ‚ค๋ ค๋ฉด ์†Œ๋น„์ž๊ฐ€ attributes ๋กœ tabindex="0" ๊ณผ role="region" ยท aria-label ์„ ์ง์ ‘ ๋„ฃ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • showScrollbar: false ๋Š” ๋„˜์นจ์„ ์•Œ๋ฆฌ๋Š” ์œ ์ผํ•œ ์‹œ๊ฐ ๋‹จ์„œ๋ฅผ ์—†์• ๋ฉด์„œ ๋Œ€์ฒด ์–ดํฌ๋˜์Šค๋ฅผ ์ œ๊ณตํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ์ฝ˜ํ…์ธ ๊ฐ€ ๋” ์žˆ๋‹ค๋Š” ์‚ฌ์‹ค์ด ์‹œ๊ฐ์ ์œผ๋กœ๋„ ์Œ์„ฑ์œผ๋กœ๋„ ๋“œ๋Ÿฌ๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • controller ๋Š” Flutter ์ „์šฉ์ด๋ผ ํฌ๋กœ์Šค ํ”Œ๋žซํผ ํ”„๋กœ๊ทธ๋ž˜๋งคํ‹ฑ ์Šคํฌ๋กค ๊ฒฝ๋กœ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. "ํŠน์ • ํ•ญ๋ชฉ์œผ๋กœ ์Šคํฌ๋กค" ๊ฐ™์€ ๋™์ž‘์„ ์–‘ ํ”Œ๋žซํผ์—์„œ ๋™์ผํ•˜๊ฒŒ ์ œ๊ณตํ•˜๋ ค๋ฉด ์†Œ๋น„์ž ์ชฝ์—์„œ ํ”Œ๋žซํผ๋ณ„๋กœ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

reduced motion ยท ๊ณ ๋Œ€๋น„ ยท ์ตœ์†Œ ํ„ฐ์น˜ ํƒ€๊ฒŸ ๋“ฑ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ฐ€๋กœ์ง€๋ฅด๋Š” ์ถ•์€ ์ „์—ญ ์ ‘๊ทผ์„ฑ ์ถ•์—์„œ ๋‹ค๋ฃน๋‹ˆ๋‹ค.