Thursday, July 6, 2023

플러터 스크롤의 정석: 특정 위젯으로 정밀하게 이동하기

플러터(Flutter) 애플리케이션을 개발하다 보면 사용자의 특정 행동에 반응하여 화면을 스크롤해야 하는 경우가 빈번하게 발생합니다. 예를 들어, 긴 فرم(form)에서 유효성 검사에 실패한 입력 필드로 사용자를 안내하거나, 채팅 앱에서 새로운 메시지가 도착했을 때 화면 하단으로 자동으로 스크롤하는 기능, 혹은 사용자가 목차의 항목을 탭했을 때 해당 콘텐츠 영역으로 부드럽게 이동시키는 기능 등이 대표적입니다. 이처럼 프로그래매틱(programmatic) 스크롤은 사용자 경험(UX)을 크게 향상시키는 핵심적인 요소입니다.

플러터는 이러한 요구사항을 해결하기 위해 강력하고 유연한 여러 메커니즘을 제공합니다. 가장 대표적인 두 가지 방법은 GlobalKeyScrollable.ensureVisible()을 함께 사용하는 방식과, ScrollController를 이용해 스크롤 위치를 직접 제어하는 방식입니다. 이 두 가지 방법은 각각의 장단점과 적합한 사용 사례를 가지고 있으며, 이를 정확히 이해하고 적용하는 것이 중요합니다.

이 글에서는 이 두 가지 핵심적인 스크롤 제어 기법을 심도 있게 다룹니다. 먼저 GlobalKey의 본질과 Scrollable.ensureVisible() 메서드의 다양한 옵션을 활용하여 SingleChildScrollView 내의 특정 위젯으로 이동하는 방법을 상세히 알아봅니다. 이후 ListView와 같이 동적으로 생성되는 긴 목록에서 왜 GlobalKey 방식이 비효율적인지를 살펴보고, 이에 대한 대안으로 ScrollController를 사용하는 효과적인 방법을 구체적인 예제 코드와 함께 설명합니다. 마지막으로, 실제 프로젝트에서 마주할 수 있는 다양한 시나리오와 잠재적인 문제점, 그리고 해결 방안까지 제시하여 플러터에서 스크롤을 자유자재로 다룰 수 있는 견고한 기반을 마련해 드릴 것입니다.


1. GlobalKey와 Scrollable.ensureVisible: 정적 콘텐츠 스크롤의 기본

화면에 표시되는 위젯의 수가 많지 않고, 그 구조가 동적으로 변하지 않는 정적인 화면(예: 설정 페이지, 회원가입 폼)에서는 GlobalKeyScrollable.ensureVisible()의 조합이 가장 직관적이고 효과적인 해결책입니다. 이 방식의 핵심은 이동하고자 하는 목표 위젯에 고유한 식별자인 GlobalKey를 부여하고, 필요할 때 이 키를 사용해 해당 위젯의 위치 정보를 얻어와 화면에 보이도록 스크롤하는 것입니다.

GlobalKey의 이해: 단순한 식별자를 넘어서

플러터에서 'Key'는 위젯을 식별하고, 위젯 트리가 재구성될 때 상태를 보존하는 데 사용되는 중요한 개념입니다. Key에는 여러 종류가 있지만, 프로그래매틱 스크롤과 관련하여 가장 중요한 것은 GlobalKey입니다.

LocalKey(예: ValueKey, ObjectKey, UniqueKey)가 동일한 부모 위젯 내에서 형제 위젯들 사이의 식별을 위해 사용되는 반면, GlobalKey는 이름 그대로 애플리케이션 전체에서 유일한 키입니다. 이 고유성 덕분에 위젯 트리의 어느 위치에서든 특정 GlobalKey를 가진 위젯의 정보에 접근할 수 있습니다. GlobalKey는 단순히 위젯을 식별하는 것을 넘어, 해당 위젯과 관련된 중요한 세 가지 정보, 즉 currentWidget, currentState, currentContext에 대한 참조를 제공합니다.

  • currentWidget: 현재 위젯 트리에 마운트된 위젯 인스턴스 자체에 접근합니다.
  • currentState: StatefulWidget의 경우, 그 위젯의 State 객체에 접근할 수 있게 해줍니다. 이를 통해 외부에서 해당 위젯의 내부 상태를 변경하거나 메서드를 호출하는 등의 상호작용이 가능합니다.
  • currentContext: 위젯 트리에서 해당 위젯의 위치와 관련된 정보를 담고 있는 BuildContext에 접근합니다. Scrollable.ensureVisible()은 바로 이 BuildContext를 사용하여 위젯의 렌더링된 위치와 크기를 파악합니다.

이러한 특성 때문에 GlobalKey는 강력하지만, 남용될 경우 애플리케이션의 구조를 복잡하게 만들고 성능에 영향을 줄 수 있으므로 꼭 필요한 경우에만 신중하게 사용해야 합니다.

구현 단계별 상세 가이드

이제 GlobalKeyScrollable.ensureVisible()을 사용하여 스크롤을 구현하는 과정을 단계별로 살펴보겠습니다.

1단계: GlobalKey 인스턴스 생성

먼저, 스크롤의 대상이 될 위젯을 참조하기 위한 GlobalKey 인스턴스를 생성합니다. 보통 StatefulWidgetState 클래스 내에 멤버 변수로 선언합니다.


class _MyScrollPageState extends State<MyScrollPage> {
  // 특정 위젯을 참조하기 위한 GlobalKey 생성
  final GlobalKey _targetKey = GlobalKey();

  // ... 나머지 코드
}

2단계: 목표 위젯에 GlobalKey 할당

생성한 GlobalKey를 스크롤하고자 하는 목표 위젯의 key 속성에 할당합니다. 이로써 _targetKey는 해당 Container 위젯과 연결됩니다.


// ... 위젯 빌드 메서드 내부
SingleChildScrollView(
  child: Column(
    children: [
      // ... 다른 위젯들
      Container(
        key: _targetKey, // 생성한 키를 위젯에 할당
        height: 300,
        color: Colors.redAccent,
        alignment: Alignment.center,
        child: Text('여기가 목표 지점입니다!', style: TextStyle(color: Colors.white, fontSize: 20)),
      ),
      // ... 다른 위젯들
    ],
  ),
)

3단계: Scrollable.ensureVisible() 호출

마지막으로, 스크롤을 실행할 트리거(예: 버튼 클릭)가 발생했을 때 Scrollable.ensureVisible() 메서드를 호출합니다. 이 메서드는 인자로 받은 BuildContext에 해당하는 위젯이 뷰포트(viewport, 화면에 보이는 영역)에 완전히 보이도록 스크롤 위치를 조정합니다.


void _scrollToTarget() {
  // GlobalKey를 통해 현재 BuildContext에 접근합니다.
  final context = _targetKey.currentContext;

  // context가 null이 아닌지 확인하는 것이 중요합니다.
  // 위젯이 아직 렌더링되지 않았거나 화면에서 사라진 경우 null일 수 있습니다.
  if (context != null) {
    Scrollable.ensureVisible(context);
  }
}

// ...
ElevatedButton(
  onPressed: _scrollToTarget,
  child: Text('목표 지점으로 스크롤'),
)

Scrollable.ensureVisible()의 고급 옵션 활용하기

Scrollable.ensureVisible()는 단순히 위젯을 화면에 보이게 하는 것 이상의 세밀한 제어를 위한 여러 옵셔널 파라미터를 제공합니다. 이를 활용하면 훨씬 더 정교하고 부드러운 사용자 경험을 만들 수 있습니다.

  • duration: 스크롤 애니메이션이 진행될 시간을 지정합니다. 기본값은 Duration.zero로, 즉시 이동합니다. 부드러운 이동을 원한다면 Duration(milliseconds: 500)과 같이 값을 지정할 수 있습니다.
  • curve: 스크롤 애니메이션의 속도 변화를 제어하는 곡선입니다. Curves 클래스에 정의된 다양한 프리셋(예: Curves.easeInOut, Curves.bounceOut)을 사용하여 다채로운 애니메이션 효과를 줄 수 있습니다.
  • alignment: 목표 위젯이 뷰포트 내 어느 위치에 표시될지를 결정합니다.
    • 0.0: 뷰포트의 시작 부분(세로 스크롤의 경우 상단)에 위치시킵니다.
    • 0.5: 뷰포트의 중앙에 위치시킵니다. (기본값)
    • 1.0: 뷰포트의 끝 부분(세로 스크롤의 경우 하단)에 위치시킵니다.

이 옵션들을 적용한 예제는 다음과 같습니다.


void _scrollToTargetWithAnimation() {
  final context = _targetKey.currentContext;
  if (context != null) {
    Scrollable.ensureVisible(
      context,
      duration: const Duration(seconds: 1), // 1초 동안 애니메이션
      curve: Curves.easeInOutCubic, // 부드럽게 가속하고 감속하는 커브
      alignment: 0.0, // 뷰포트 상단에 맞춤
    );
  }
}

이처럼 옵션을 조합하면, "버튼을 누르면 1초 동안 부드럽게 스크롤하여 목표 위젯이 화면 상단에 정확히 위치하도록" 하는 복합적인 동작을 손쉽게 구현할 수 있습니다.

전체 예제 코드: GlobalKey 방식

지금까지 설명한 내용을 종합한 전체 예제 코드는 다음과 같습니다.


import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Programmatic Scroll Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const ScrollDemoScreen(),
    );
  }
}

class ScrollDemoScreen extends StatefulWidget {
  const ScrollDemoScreen({Key? key}) : super(key: key);

  @override
  State<ScrollDemoScreen> createState() => _ScrollDemoScreenState();
}

class _ScrollDemoScreenState extends State<ScrollDemoScreen> {
  final GlobalKey _targetKey = GlobalKey();

  // 스크롤을 실행하는 함수
  void _scrollToTarget() {
    final targetContext = _targetKey.currentContext;
    if (targetContext != null) {
      Scrollable.ensureVisible(
        targetContext,
        duration: const Duration(milliseconds: 600),
        curve: Curves.easeInOut,
        alignment: 0.5, // 뷰포트 중앙에 위치
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll to Widget with GlobalKey'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            const Text(
              '아래 버튼을 눌러 특정 위젯으로 스크롤하세요.',
              style: TextStyle(fontSize: 18),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _scrollToTarget,
              child: const Text('빨간색 컨테이너로 스크롤'),
            ),
            const SizedBox(height: 400),
            Container(
              height: 200,
              color: Colors.blue[200],
              alignment: Alignment.center,
              child: const Text('위젯 1', style: TextStyle(fontSize: 24)),
            ),
            const SizedBox(height: 400),
            // 스크롤의 목표가 되는 위젯
            Container(
              key: _targetKey,
              height: 200,
              color: Colors.red[400],
              alignment: Alignment.center,
              child: const Text(
                '목표 지점!',
                style: TextStyle(fontSize: 24, color: Colors.white),
              ),
            ),
            const SizedBox(height: 400),
            Container(
              height: 200,
              color: Colors.green[200],
              alignment: Alignment.center,
              child: const Text('위젯 3', style: TextStyle(fontSize: 24)),
            ),
            const SizedBox(height: 400),
          ],
        ),
      ),
    );
  }
}

이 코드를 실행하고 버튼을 누르면, 화면이 부드럽게 스크롤되어 빨간색 컨테이너가 화면 중앙에 나타나는 것을 확인할 수 있습니다. 이것이 GlobalKey를 이용한 스크롤 제어의 기본 원리입니다.


2. ScrollController: 동적 목록 스크롤의 전문가

GlobalKey 방식은 SingleChildScrollView와 같이 모든 자식 위젯을 한 번에 빌드하는 경우에는 훌륭하게 작동합니다. 하지만 ListView.builderGridView.builder와 같이 화면에 보이지 않는 위젯은 메모리에서 해제하고, 필요할 때 다시 빌드하는 '지연 로딩(lazy loading)' 방식을 사용하는 위젯에서는 심각한 문제를 야기할 수 있습니다.

ListView.builder에서 GlobalKey가 부적합한 이유

수백, 수천 개의 아이템을 가진 리스트를 상상해 봅시다. 만약 각 아이템에 GlobalKey를 할당하려고 하면 두 가지 큰 문제에 부딪힙니다.

  1. 키 충돌 문제: ListView.builder는 화면을 스크롤할 때 위젯을 재활용합니다. 즉, 화면 밖으로 사라진 위젯 객체를 새로운 데이터를 담아 다시 화면에 표시합니다. 만약 100개의 아이템에 100개의 GlobalKey를 담은 리스트를 만들고 이를 itemBuilder에서 사용한다면, 위젯이 재활용되는 순간 이전에 사용되었던 GlobalKey가 새로운 위젯에 다시 할당되면서 "multiple widgets used the same GlobalKey"라는 치명적인 오류가 발생할 수 있습니다.
  2. 메모리 및 성능 문제: GlobalKey는 일반 위젯보다 더 많은 리소스를 소비합니다. 수많은 아이템에 각각 GlobalKey를 할당하는 것은 그 자체로 상당한 메모리 부담을 유발하며, 플러터의 위젯 트리 관리 성능을 저하시킬 수 있습니다.

또한, 아직 빌드되지 않은(화면에 보이지 않는) 위젯은 BuildContext를 가지고 있지 않으므로, GlobalKey를 통해 접근하더라도 currentContextnull이 되어 Scrollable.ensureVisible()을 호출할 수 없습니다.

이러한 문제들을 해결하기 위해 동적 목록 스크롤에는 ScrollController가 사용됩니다.

ScrollController의 핵심 원리

ScrollController는 이름 그대로 스크롤 가능한 위젯의 스크롤 상태를 제어하고 감시하는 객체입니다. GlobalKey가 특정 '위젯'에 대한 참조를 제공한다면, ScrollController는 '스크롤 위치(offset)'에 대한 직접적인 제어권을 제공합니다.

ScrollController의 주요 메서드는 다음과 같습니다.

  • jumpTo(double offset): 아무런 애니메이션 없이 즉시 지정된 픽셀 오프셋으로 스크롤 위치를 이동시킵니다.
  • animateTo(double offset, {required Duration duration, required Curve curve}): 지정된 시간과 애니메이션 커브를 사용하여 부드럽게 목표 오프셋으로 스크롤합니다. 이는 Scrollable.ensureVisible의 애니메이션 기능과 유사합니다.

이를 사용하면 특정 인덱스의 아이템으로 이동하는 로직을 "해당 인덱스의 아이템이 시작되는 픽셀 오프셋을 계산하여 그 위치로 이동"하는 방식으로 구현할 수 있습니다.

구현 단계별 상세 가이드

1단계: ScrollController 인스턴스 생성 및 연결

State 클래스 내에 ScrollController 인스턴스를 생성하고, dispose() 메서드에서 반드시 컨트롤러를 해제하여 메모리 누수를 방지해야 합니다. 그리고 이 컨트롤러를 ListView.buildercontroller 속성에 연결합니다.


class _MyListPageState extends State<MyListPage> {
  final ScrollController _scrollController = ScrollController();
  final int _itemCount = 100;
  final double _itemHeight = 80.0;

  @override
  void dispose() {
    _scrollController.dispose(); // 컨트롤러를 반드시 dispose해야 합니다.
    super.dispose();
  }
  
  // ...
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: ListView.builder(
        controller: _scrollController, // 컨트롤러 연결
        itemCount: _itemCount,
        itemExtent: _itemHeight, // 아이템 높이가 고정된 경우 itemExtent 사용이 성능에 유리
        itemBuilder: (context, index) {
          // ...
        },
      ),
    );
  }
}

2단계: 목표 오프셋 계산 및 스크롤 실행

특정 인덱스로 이동하기 위한 함수를 작성합니다. 가장 간단한 경우는 모든 아이템의 높이가 동일할 때입니다. 이때 목표 오프셋은 `(인덱스) * (아이템 높이)`로 쉽게 계산할 수 있습니다.


void _scrollToIndex(int index) {
  // 인덱스가 유효한 범위 내에 있는지 확인
  if (index < 0 || index >= _itemCount) return;

  // 목표 오프셋 계산
  final double targetOffset = index * _itemHeight;

  _scrollController.animateTo(
    targetOffset,
    duration: const Duration(milliseconds: 500),
    curve: Curves.easeOut,
  );
}

만약 스크롤 가능한 최대 범위를 넘어서는 오프셋을 지정하면, 컨트롤러는 자동으로 스크롤 가능한 최대 위치(리스트의 가장 끝)로 이동시켜 줍니다.

전체 예제 코드: ScrollController 방식

다음은 ScrollController를 사용하여 ListView.builder의 특정 인덱스로 스크롤하는 전체 예제입니다.


import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ScrollController Demo',
      home: ListScrollScreen(),
    );
  }
}

class ListScrollScreen extends StatefulWidget {
  @override
  _ListScrollScreenState createState() => _ListScrollScreenState();
}

class _ListScrollScreenState extends State<ListScrollScreen> {
  final ScrollController _scrollController = ScrollController();
  final int _itemCount = 100;
  final double _itemHeight = 60.0;
  final TextEditingController _textController = TextEditingController();

  @override
  void dispose() {
    _scrollController.dispose();
    _textController.dispose();
    super.dispose();
  }

  void _scrollToIndex() {
    final index = int.tryParse(_textController.text);
    if (index != null && index >= 0 && index < _itemCount) {
      _scrollController.animateTo(
        index * _itemHeight,
        duration: const Duration(seconds: 1),
        curve: Curves.easeInOutBack,
      );
    } else {
      // 사용자에게 유효하지 않은 입력임을 알림
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('0부터 ${_itemCount - 1} 사이의 숫자를 입력해주세요.'),
          duration: const Duration(seconds: 2),
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll to Index with ScrollController'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _textController,
                    keyboardType: TextInputType.number,
                    decoration: const InputDecoration(
                      labelText: '이동할 인덱스 입력',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: _scrollToIndex,
                  child: const Text('이동'),
                ),
              ],
            ),
          ),
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: _itemCount,
              itemExtent: _itemHeight, // 모든 아이템 높이가 동일함을 명시
              itemBuilder: (context, index) {
                return Card(
                  color: index % 2 == 0 ? Colors.grey[200] : Colors.white,
                  child: ListTile(
                    leading: CircleAvatar(child: Text('$index')),
                    title: Text('아이템 #$index'),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

이 예제는 사용자가 입력 필드에 인덱스를 입력하고 '이동' 버튼을 누르면, 해당 인덱스의 아이템으로 부드럽게 스크롤되는 기능을 보여줍니다. 이는 GlobalKey를 사용하지 않고도 동적 목록에서 효율적으로 특정 위치로 이동할 수 있음을 증명합니다.

고급 주제: 가변 높이 아이템 스크롤하기

실제 애플리케이션에서는 모든 리스트 아이템의 높이가 동일하지 않은 경우가 많습니다. 이 경우 `(인덱스) * (아이템 높이)`와 같은 단순한 계산은 불가능합니다. 이 문제를 해결하기 위한 몇 가지 접근법이 있습니다.

  1. 오프셋 미리 계산하기: 리스트 데이터를 로드할 때 각 아이템의 높이를 미리 계산하고, 각 인덱스까지의 누적 오프셋을 저장해두는 방식입니다. 이는 데이터가 많지 않고 아이템의 높이가 내용에 따라 결정될 때 유용할 수 있지만, 복잡하고 부정확할 수 있습니다.
  2. 패키지 사용하기: 이 복잡한 문제를 해결하기 위해 커뮤니티에서 개발한 훌륭한 패키지들이 있습니다. 대표적으로 scrollable_positioned_list는 가변 높이를 가진 아이템 리스트에서도 특정 인덱스로 정확하게 스크롤하는 기능을 매우 간단하게 구현할 수 있도록 도와줍니다. 내부적으로 복잡한 계산을 추상화하여 개발자가 itemScrollController.scrollTo(index: 50)와 같이 직관적인 코드를 작성할 수 있게 해줍니다.

3. 올바른 선택: SingleChildScrollView vs. ListView

지금까지 두 가지 주요 스크롤 기법을 살펴보았습니다. 어떤 상황에서 어떤 방법을 선택해야 할까요? 이는 결국 어떤 스크롤 위젯을 사용하느냐에 따라 결정됩니다.

SingleChildScrollView

핵심 원리: 자식 위젯을 모두 한 번에 빌드하고 렌더링합니다. 뷰포트는 이 거대한 위젯의 일부를 보여주는 창 역할을 합니다.

장점:

  • 구현이 간단하고 직관적입니다.
  • 자식 위젯의 구조가 복잡하거나 서로 다른 종류의 위젯이 섞여 있을 때 유용합니다.
  • GlobalKey를 사용하여 특정 위젯으로 이동하는 것이 매우 안정적입니다.

단점:

  • 자식 위젯의 수가 많아지면 초기 빌드 시간이 길어지고 메모리 사용량이 급격히 증가합니다.
  • 성능 저하의 주된 원인이 될 수 있습니다.

주요 사용 사례:

  • 회원가입, 로그인 등 필드 수가 제한적인 폼 페이지
  • 앱 설정 화면
  • 내용의 양이 많지 않은 상세 정보 페이지

ListView (특히 ListView.builder)

핵심 원리: 화면에 보이는 아이템과 그 주변의 일부 아이템(캐시 영역)만 빌드합니다. 스크롤 시 위젯을 재활용하여 효율성을 극대화합니다.

장점:

  • 아이템 수가 거의 무한대에 가까워도 일정한 메모리 사용량과 빠른 성능을 유지합니다.
  • 대용량 데이터를 처리하는 데 최적화되어 있습니다.

단점:

  • GlobalKey를 사용하는 방식이 부적합하며, ScrollController를 사용해야 합니다.
  • 아이템 높이가 가변적일 경우 특정 인덱스로 스크롤하는 로직이 복잡해질 수 있습니다.

주요 사용 사례:

  • 채팅 앱의 메시지 목록
  • SNS의 피드
  • 연락처, 상품 목록 등 동적으로 로드되는 긴 리스트

결론적으로, 화면에 표시될 콘텐츠의 양이 명확하게 제한되어 있고 예측 가능하다면 SingleChildScrollViewGlobalKey가 좋은 선택입니다. 반면, 데이터의 양이 많거나 가변적이라면 반드시 ListView.builderScrollController를 사용하여 애플리케이션의 성능과 안정성을 확보해야 합니다.


4. 실제 적용과 주의사항

이론을 배웠으니 이제 실제 프로젝트에서 발생할 수 있는 몇 가지 중요한 문제와 해결책을 짚어보겠습니다.

주의사항 1: 위젯이 렌더링되기 전에 스크롤 호출하기

initState() 메서드 안에서 Scrollable.ensureVisible()이나 _scrollController.jumpTo()를 호출하면 어떻게 될까요? 대부분의 경우 아무 일도 일어나지 않거나 오류가 발생합니다. 왜냐하면 initState()가 호출되는 시점에는 위젯 트리가 아직 완전히 구성되지 않았고, 화면에 렌더링되지 않았기 때문입니다. 따라서 스크롤할 대상 위젯의 위치나 스크롤 뷰의 전체 크기를 알 수 없습니다.

이 문제를 해결하기 위해 WidgetsBinding.instance.addPostFrameCallback을 사용합니다. 이 콜백은 플러터 엔진이 현재 프레임의 빌드와 렌더링을 모두 마친 직후에 실행되도록 보장해줍니다.


@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    // 이 시점에서는 위젯의 크기와 위치 계산이 완료되었습니다.
    _scrollToTarget(); 
  });
}

이 패턴은 화면이 처음 로드될 때 특정 위치로 스크롤해야 하는 요구사항(예: 특정 아이템을 강조하며 화면 시작)을 구현할 때 필수적입니다.

주의사항 2: `_globalKey.currentContext`가 null인 경우

Scrollable.ensureVisible(_globalKey.currentContext)를 호출할 때 _globalKey.currentContextnull이어서 NullPointerException이 발생하는 경우가 있습니다. 이는 다음과 같은 상황에서 발생할 수 있습니다.

  • GlobalKey가 할당된 위젯이 아직 위젯 트리에 추가되지 않았을 때 (위 `initState` 문제와 유사)
  • GlobalKey가 할당된 위젯이 조건부 렌더링(예: `if (someCondition) ...`)에 의해 현재 위젯 트리에 포함되지 않았을 때
  • ListView.builder와 같이 위젯이 재활용되면서 화면 밖으로 나가 현재 트리에 존재하지 않을 때

따라서 Scrollable.ensureVisible를 호출하기 전에는 항상 if (_globalKey.currentContext != null)과 같이 null 체크를 해주는 것이 안정적인 코드를 작성하는 좋은 습관입니다.

플러터에서 프로그래밍 방식으로 스크롤을 제어하는 것은 사용자 경험을 한 차원 높이는 강력한 도구입니다. 정적인 콘텐츠에는 GlobalKeyScrollable.ensureVisible를, 동적이고 긴 목록에는 ScrollController를 사용하는 기본 원칙을 기억하고, 위젯의 생명주기를 고려하여 적절한 시점에 스크롤 메서드를 호출한다면, 어떤 복잡한 스크롤 요구사항도 자신 있게 구현할 수 있을 것입니다.


0 개의 댓글:

Post a Comment