Flutter: 콘텐츠 길이에 따라 버튼 위치가 바뀌는 UI 구현 (SliverFillRemaining 완벽 해부)

회원가입 폼이나 약관 동의 페이지를 개발할 때, Flutter 개발자들이 가장 흔하게 겪는 레이아웃 딜레마가 있습니다. 콘텐츠가 짧으면 버튼이 화면 최하단에 예쁘게 고정되어야 하고, 콘텐츠가 길어지면 자연스럽게 스크롤 영역의 끝부분으로 밀려나야 합니다. 많은 분들이 이를 해결하기 위해 MediaQuery로 화면 높이를 계산하거나 Stack을 사용하지만, 키보드가 올라올 때 레이아웃이 깨지거나 콘텐츠가 가려지는 오버플로우(Overflow) 문제를 피할 수 없습니다. 오늘은 이 문제를 CustomScrollViewsliver 조합으로 가장 우아하게 해결하는 방법을 공유합니다.

콘텐츠 길이에 따른 버튼 위치 변화 예시
(좌) 짧은 콘텐츠: 하단 고정 / (우) 긴 콘텐츠: 스크롤 끝에 위치

Why Stack & Column Fail

이 문제를 접했을 때 가장 먼저 떠오르는 접근 방식은 Stack 위젯입니다. 하지만 Stack은 치명적인 단점이 있습니다.

Stack 사용 시 문제점 (Anti-Pattern)
Positioned(bottom: 0)으로 버튼을 고정하면, 콘텐츠가 길어졌을 때 버튼이 콘텐츠 위를 덮어버립니다(Overlay). 사용자는 버튼 뒤에 있는 텍스트나 입력 필드를 볼 수 없게 되며, 이를 해결하기 위해 마지막 위젯에 SizedBox로 강제 여백을 주는 방식은 유지보수성을 크게 떨어뜨립니다.

또 다른 방법인 Column + Expanded 조합은 SingleChildScrollView 내부에서 작동하지 않습니다. 스크롤 뷰는 무한한 높이를 가지기 때문에 Expanded가 공간을 계산할 수 없어 렌더링 에러를 뱉어냅니다. 이 문제의 핵심 해결책은 바로 Sliver 레이아웃 시스템을 활용하는 것입니다.

The Solution: SliverFillRemaining

가장 깔끔한 해결책은 CustomScrollView 내부에 SliverFillRemaining을 배치하는 것입니다. 이 위젯은 화면에 남는 공간이 있으면 그 공간을 차지하고, 콘텐츠가 화면보다 길면 일반적인 스크롤 뷰의 일부처럼 동작합니다.

여기서 핵심 속성은 hasScrollBody: false입니다. 이 속성을 false로 설정해야 내부 콘텐츠가 스크롤 가능 영역이 아니라 단순 레이아웃 영역으로 인식되어, 우리가 원하는 "남는 공간 채우기" 동작을 수행합니다.

import 'package:flutter/material.dart';

class DynamicButtonPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Sliver Layout Pattern")),
      body: CustomScrollView(
        slivers: [
          // 1. 메인 콘텐츠 (가변 길이)
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text("약관 내용", style: Theme.of(context).textTheme.headlineSmall),
                  SizedBox(height: 10),
                  // 테스트를 위해 긴 텍스트를 넣어보세요.
                  Text(
                    "여기에 아주 긴 텍스트가 들어가거나 짧은 텍스트가 들어갑니다..." * 20, 
                    style: TextStyle(fontSize: 16, height: 1.5),
                  ),
                ],
              ),
            ),
          ),

          // 2. 남은 공간을 채우는 영역 (핵심 솔루션)
          SliverFillRemaining(
            hasScrollBody: false, // 중요: 스크롤 불가능한 단순 컨테이너로 처리
            child: Column(
              children: [
                // Spacer가 남는 공간을 모두 밀어내어 버튼을 바닥으로 내림
                Spacer(), 
                
                // 하단 버튼 영역
                Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: SizedBox(
                    width: double.infinity,
                    height: 50,
                    child: ElevatedButton(
                      onPressed: () {},
                      child: Text("동의하고 계속하기"),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
CustomScrollView와 slivers를 활용한 동적 레이아웃 구현 애니메이션
CustomScrollView와 SliverFillRemaining의 실제 동작 모습
Code Analysis
  • SliverToBoxAdapter: 일반적인 위젯(Column, Text 등)을 Sliver 프로토콜로 변환해줍니다.
  • SliverFillRemaining: 뷰포트(화면)의 남은 공간을 계산합니다.
  • hasScrollBody: false: 내부 자식이 자체적인 스크롤 기능을 가지지 않음을 선언합니다. 이를 통해 Spacer()가 남은 높이만큼 정확히 확장될 수 있습니다.

Performance & Behavior Verification

이 패턴이 LayoutBuilder를 사용하는 방식보다 우월한 이유는 Flutter 프레임워크의 레이아웃 패스(Layout Pass)를 효율적으로 사용하기 때문입니다. 수동으로 높이를 계산하지 않고, Sliver 프로토콜에 위임하므로 디바이스 회전이나 키보드 등판 시에도 유연하게 반응합니다.

접근 방식 콘텐츠 < 화면 높이 콘텐츠 > 화면 높이 평가
Stack + Positioned 화면 하단 고정 (성공) 콘텐츠 덮음 (실패) ❌ 비추천
Column + Spacer 화면 하단 고정 (성공) RenderFlex Overflow (에러) ❌ 사용 불가
SliverFillRemaining 화면 하단 고정 (성공) 스크롤 끝에 배치 (성공) Best Practice

Conclusion

Flutter에서 "반응형 레이아웃"이란 단순히 화면 크기에 맞추는 것이 아니라, 콘텐츠의 양에 따라 유기적으로 변하는 UI를 의미하기도 합니다. SliverFillRemaining은 이러한 요구사항을 코드 몇 줄로 완벽하게 처리해 줍니다. 억지로 높이를 계산하려 하지 말고, Sliver에게 레이아웃 위임을 맡기세요. 이것이 Flutter가 지향하는 선언형 UI의 장점입니다.

1 comment