[Flutter] ListView.builder에서 가변 크기 요소들의 무한 스크롤 구현하기

2025. 7. 10. 17:05Programming/Flutter

반응형

문제 상황

ListView.builder를 사용해서 무한 스크롤을 구현하려고 하는데, 문제는 리스트 내부에 표현해야하는 요소들의 값이 가변적인 높이를 가지고 있다는 점이었다. 별 생각없이 높이로 하드코딩된 값을 지정하고 구현했을 때야 동작이야 하긴 하지만, 태블릿처럼 화면이 커지거나 레이아웃이 변경될 경우에는 하드코딩된 값을 지정하기 어렵다는 문제도 있었다. 이번에는 이런 경우를 대비해서 ListView.builder에 GlobalKey를 할당하고, ListView의 자식 요소의 높이값을 직접 계산하는 방법을 살펴본다.

ListView.builder의 자식 요소의 높이가 가변적으로 달라지는 예시로는 다음과 같은 상황이 있다.

  • 텍스트의 길이에 따라 카드 높이가 달라지는 경우
  • 이미지 비율에 따라 높이가 변하는 경우
  • 날짜 구분선이나 헤더가 포함된 경우

이런 상황에서 하드코딩된 값을 사용하게 될 경우 날짜 구분선이나 헤더에 의해 일부 요소의 높이가 틀어지는 경우, 스크롤 위치가 미묘하게 바뀌어 사용자 입장에서는 이질감을 느낄 수 있다.

동적 크기 계산 접근법

Flutter에서는 GlobalKey와 RenderBox를 활용해서 ListView에 포함된 자식 요소의 높이를 계산할 수 있다. 단, 렌더링이 끝난 뒤에만 계산이 가능하다는 점에 유의하자.

예제 시나리오: 메시지 리스트

실제 구현을 위해 다음과 같은 메시지 리스트를 예제로 사용해보자:

class Message {
  final String id;
  final String content;
  final DateTime timestamp;
  final bool isSystemMessage;

  Message({
    required this.id,
    required this.content,
    required this.timestamp,
    this.isSystemMessage = false,
  });
}

이 메시지 리스트는 다음과 같은 특징을 가진다:

  • 일반 메시지: 컨텐츠 길이에 따라 높이가 달라짐
  • 시스템 메시지: 날짜 구분선 역할을 하며 다른 스타일 적용
  • 각 메시지마다 타임스탬프 표시

1단계: 동적 크기 계산을 위한 기본 구조

먼저 각 아이템의 크기를 추적하기 위한 기본 구조를 만든다:

class DynamicListViewModel extends ChangeNotifier {
  List<Message> messages = [];
  List<GlobalKey> itemKeys = [];

  // 동적 크기 계산을 위한 필드
  double? _baseItemHeight;
  double? _systemMessageHeight;
  bool _isHeightCalibrated = false;
  final Map<int, double> _heightCache = {};

  ScrollController scrollController = ScrollController();

  bool get isHeightCalibrated => _isHeightCalibrated;
}

2단계: 실제 렌더링된 크기 추출

GlobalKey를 사용해서 실제 렌더링된 위젯의 크기를 가져오는 메서드를 구현한다:

double? getActualItemHeight(int index) {
  if (index >= 0 && index < itemKeys.length) {
    final GlobalKey key = itemKeys[index];
    if (key.currentContext != null) {
      try {
        final RenderBox renderBox = key.currentContext!.findRenderObject() as RenderBox;
        return renderBox.size.height;
      } catch (e) {
        debugPrint('Error getting item height for index $index: $e');
        return null;
      }
    }
  }
  return null;
}

3단계: 기준 높이 캘리브레이션

초기 렌더링 후 실제 크기를 기반으로 기준 높이를 설정한다:

void calibrateBaseHeights() {
  if (_isHeightCalibrated || messages.isEmpty) return;

  debugPrint('Starting height calibration with ${messages.length} items');

  double? foundBaseHeight;
  double? foundSystemHeight;

  // 처음 몇 개 아이템에서 기준 높이 추출
  for (int i = 0; i < min(10, messages.length); i++) {
    final double? actualHeight = getActualItemHeight(i);
    if (actualHeight == null) continue;

    if (!messages[i].isSystemMessage && foundBaseHeight == null) {
      foundBaseHeight = actualHeight;
      debugPrint('Base item height calibrated: $foundBaseHeight');
    } else if (messages[i].isSystemMessage && foundSystemHeight == null) {
      foundSystemHeight = actualHeight;
      debugPrint('System message height calibrated: $foundSystemHeight');
    }

    if (foundBaseHeight != null && foundSystemHeight != null) break;
  }

  if (foundBaseHeight != null) {
    _baseItemHeight = foundBaseHeight;
    _systemMessageHeight = foundSystemHeight ?? foundBaseHeight;
    _isHeightCalibrated = true;
    _heightCache.clear();

    debugPrint('Height calibration completed - base: $_baseItemHeight, system: $_systemMessageHeight');
    notifyListeners();
  }
}

4단계: 추정 높이 계산 (캐싱 포함)

실제 크기를 우선 사용하되, 없을 경우 캘리브레이션된 값을 사용하는 메서드를 만든다:

double getEstimatedItemHeight(int index) {
  // 캐시 확인
  if (_heightCache.containsKey(index)) {
    return _heightCache[index]!;
  }

  // 실제 렌더링된 높이 우선 사용
  final double? actualHeight = getActualItemHeight(index);
  if (actualHeight != null) {
    _heightCache[index] = actualHeight;
    return actualHeight;
  }

  // 캘리브레이션된 값 또는 기본값 사용
  if (index >= 0 && index < messages.length) {
    final bool isSystemMessage = messages[index].isSystemMessage;
    final double estimatedHeight = isSystemMessage 
        ? (_systemMessageHeight ?? 80.0)
        : (_baseItemHeight ?? 120.0);

    _heightCache[index] = estimatedHeight;
    return estimatedHeight;
  }

  return 120.0; // 최종 fallback
}

// 여러 아이템의 누적 높이 계산
double calculateItemsHeight(int startIndex, int count) {
  double totalHeight = 0.0;

  for (int i = startIndex; i < startIndex + count && i < messages.length; i++) {
    totalHeight += getEstimatedItemHeight(i);
  }

  return totalHeight;
}

5단계: 스크롤 위치 기반 가시 영역 계산

하드코딩된 값 대신 누적 높이를 사용해서 첫 번째 가시 아이템을 계산한다:

int calculateFirstVisibleIndex(double scrollOffset) {
  double accumulatedHeight = 0.0;

  for (int i = 0; i < messages.length; i++) {
    final double itemHeight = getEstimatedItemHeight(i);
    if (accumulatedHeight + itemHeight > scrollOffset) {
      return i;
    }
    accumulatedHeight += itemHeight;
  }

  return max(0, messages.length - 1);
}

6단계: 무한 스크롤 로직 구현

동적 크기 계산을 활용한 무한 스크롤 로직이다:

void setupScrollListener() {
  scrollController.addListener(() {
    final double offset = scrollController.offset;
    final double maxExtent = scrollController.position.maxScrollExtent;

    // 동적 계산된 가시 영역 업데이트
    final int firstVisibleIndex = calculateFirstVisibleIndex(offset);

    // 무한 스크롤 트리거 (80% 지점)
    if (offset >= maxExtent * 0.8) {
      loadNextPage();
    }

    // 메모리 최적화를 위한 아이템 제거
    if (messages.length > 150) {
      removeOldItems();
    }
  });
}

void removeOldItems() {
  const int itemsToRemove = 50;

  // 제거될 아이템들의 높이 계산
  final double removedHeight = calculateItemsHeight(0, itemsToRemove);

  // 데이터 업데이트
  messages.removeRange(0, itemsToRemove);
  itemKeys.removeRange(0, itemsToRemove);

  // 캐시 업데이트 (인덱스 변경으로 인한)
  final Map<int, double> newCache = {};
  _heightCache.forEach((key, value) {
    if (key >= itemsToRemove) {
      newCache[key - itemsToRemove] = value;
    }
  });
  _heightCache.clear();
  _heightCache.addAll(newCache);

  // 스크롤 위치 보정
  final double newOffset = scrollController.offset - removedHeight;
  scrollController.jumpTo(max(0, newOffset));

  notifyListeners();
}

7단계: UI 구현

마지막으로 UI에서 GlobalKey를 연결하고 캘리브레이션을 트리거한다:

class DynamicListView extends StatelessWidget {
  final List<Message> messages;
  final List<GlobalKey> itemKeys;
  final ScrollController scrollController;
  final VoidCallback? onItemsRendered;

  const DynamicListView({
    Key? key,
    required this.messages,
    required this.itemKeys,
    required this.scrollController,
    this.onItemsRendered,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: scrollController,
      itemCount: messages.length,
      itemBuilder: (context, index) {
        final message = messages[index];

        // 마지막 아이템 렌더링 시 캘리브레이션 트리거
        if (index == messages.length - 1) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            onItemsRendered?.call();
          });
        }

        return Container(
          key: itemKeys[index],
          child: message.isSystemMessage 
              ? _buildSystemMessage(message)
              : _buildRegularMessage(message),
        );
      },
    );
  }

  Widget _buildSystemMessage(Message message) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
      child: Center(
        child: Text(
          message.content,
          style: const TextStyle(
            fontSize: 14,
            color: Colors.grey,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }

  Widget _buildRegularMessage(Message message) {
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              message.content,
              style: const TextStyle(fontSize: 16),
            ),
            const SizedBox(height: 8),
            Text(
              DateFormat('yyyy-MM-dd HH:mm').format(message.timestamp),
              style: const TextStyle(fontSize: 12, color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

8단계: 통합 및 사용

모든 구성 요소를 통합해서 사용하는 방법이다:

class MessageListPage extends StatefulWidget {
  @override
  _MessageListPageState createState() => _MessageListPageState();
}

class _MessageListPageState extends State<MessageListPage> {
  late DynamicListViewModel viewModel;

  @override
  void initState() {
    super.initState();
    viewModel = DynamicListViewModel();
    viewModel.setupScrollListener();
    viewModel.loadInitialData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dynamic Message List')),
      body: ChangeNotifierBuilder<DynamicListViewModel>(
        notifier: viewModel,
        builder: (context, model, child) {
          return DynamicListView(
            messages: model.messages,
            itemKeys: model.itemKeys,
            scrollController: model.scrollController,
            onItemsRendered: () => model.calibrateBaseHeights(),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    viewModel.dispose();
    super.dispose();
  }
}

최종 해결책의 장점

이렇게 구현하면 다음과 같은 장점을 얻을 수 있다:

  1. 정확한 스크롤 위치 계산: 실제 렌더링된 크기를 기반으로 하므로 정확하다
  2. 화면 크기 대응: 다양한 디바이스에서 동적으로 대응 가능하다
  3. 컨텐츠 변화 대응: 텍스트 길이나 이미지 크기가 변해도 올바르게 계산된다
  4. 메모리 최적화: 오래된 아이템 제거 시에도 스크롤 위치가 정확하게 유지된다
  5. 성능 최적화: 캐싱을 통해 반복 계산을 방지한다

주의사항

구현 시 다음 사항들을 주의해야 한다:

  1. 초기 렌더링 완료 후 캘리브레이션: GlobalKey를 통한 크기 계산은 렌더링 완료 후에만 가능하다
  2. 예외 처리: RenderBox 접근 시 null 체크 및 예외 처리가 필수다
  3. 메모리 관리: 캐시 크기가 너무 커지지 않도록 주의한다
  4. 성능 고려: 스크롤 중에는 무거운 계산을 피해야한다. 또한 스크롤의 길이가 너무 길어질 경우 계산량이 많아지므로 주의해야한다.

이 방법을 사용하면 ListView.builder에서 가변 크기 아이템들을 사용하더라도 부드럽고 정확한 무한 스크롤을 구현할 수 있다. 특히 메모리 최적화를 위해 아이템을 동적으로 제거하는 경우에도 사용자가 이질감을 느끼지 않는 자연스러운 스크롤 경험을 제공할 수 있다.

반응형