Showing posts with label flutter. Show all posts
Showing posts with label flutter. Show all posts

Sunday, August 10, 2025

쉬운 바텀바 스크롤 연동Flutter 스크롤에 따라 사라지는 BottomNavigationBar, 완벽 구현 A to Z

최신 모바일 앱의 사용자 경험(UX) 트렌드 중 하나는 단연 '콘텐츠 중심 디자인'입니다. 사용자가 화면의 콘텐츠에 최대한 집중할 수 있도록 불필요한 UI 요소를 동적으로 숨기는 기법은 이제 선택이 아닌 필수가 되었습니다. 특히 인스타그램, 페이스북, 최신 웹 브라우저 등에서 흔히 볼 수 있는, 아래로 스크롤할 때는 하단 탭 바(BottomNavigationBar)가 사라지고, 위로 스크롤할 때 다시 나타나는 기능은 화면 공간을 극대화하여 사용자에게 쾌적한 경험을 선사합니다.

Flutter로 앱을 개발하면서 이러한 동적 UI를 어떻게 구현할 수 있을지 고민해보셨을 겁니다. 단순히 '보였다/안 보였다'의 이분법적인 접근을 넘어, 부드러운 애니메이션과 함께 사용자의 스크롤 의도를 정확히 파악하여 반응하는 완성도 높은 BottomNavigationBar를 만드는 것이 중요합니다. 이 글에서는 Flutter의 ScrollController, NotificationListener, 그리고 AnimationController를 조합하여, 어떤 복잡한 스크롤 뷰에서도 완벽하게 작동하는 '스크롤 연동 하단 바'를 구현하는 모든 과정을 A부터 Z까지 상세하게 다룰 것입니다. 단순히 코드를 복사 붙여넣기 하는 것을 넘어, 그 원리를 이해하고 다양한 예외 상황에 대처하는 방법까지 마스터하게 될 것입니다.

1. 기본 원리 이해: 어떻게 동작하는가?

구현에 앞서, 우리가 만들 기능의 핵심 원리를 이해하는 것이 중요합니다. 목표는 간단합니다. 사용자의 스크롤 방향을 감지하고, 그 방향에 따라 BottomNavigationBar의 위치를 화면 밖으로 밀어내거나 다시 안으로 가져오는 것입니다.

  1. 스크롤 방향 감지: 사용자가 손가락으로 화면을 위로 미는지(콘텐츠를 아래로 내리는 중), 아래로 당기는지(콘텐츠를 위로 올리는 중)를 알아내야 합니다.
  2. UI 위치 변경: 감지된 방향에 따라 BottomNavigationBar를 Y축으로 이동시킵니다. 아래로 스크롤할 때는 바의 높이만큼 아래로 내려 화면 밖으로 숨기고, 위로 스크롤할 때는 다시 원래 위치(Y=0)로 복귀시킵니다.
  3. 부드러운 전환 효과: 위치가 순간적으로 바뀌면 사용자는 부자연스러움을 느낍니다. 따라서 애니메이션을 적용하여 바가 부드럽게 슬라이드되도록 만들어야 합니다.

이 세 가지 원리를 구현하기 위해 Flutter는 다음과 같은 강력한 도구들을 제공합니다.

  • ScrollController 또는 NotificationListener: 스크롤 가능한 위젯(ListView, GridView, CustomScrollView 등)의 스크롤 이벤트를 감지하는 역할을 합니다. 특히 ScrollController는 스크롤 위치를 직접 제어할 수 있고, NotificationListener는 위젯 트리 상위에서 자식 스크롤 위젯의 다양한 알림(Notification)을 받을 수 있습니다. 우리는 이 둘을 모두 살펴보고, 더 유연한 방식인 NotificationListener를 중심으로 구현할 것입니다.
  • userScrollDirection: ScrollPosition 객체에 포함된 속성으로, 사용자의 현재 스크롤 방향을 ScrollDirection.forward(위로 스크롤), ScrollDirection.reverse(아래로 스croll), ScrollDirection.idle(정지) 세 가지 상태로 알려줍니다.
  • AnimationControllerTransform.translate: AnimationController는 애니메이션의 진행 상태(0.0 ~ 1.0)를 특정 시간 동안 관리해주는 컨트롤러입니다. 이 값을 이용하여 Transform.translate 위젯의 offset을 조절하면, 어떤 위젯이든 원하는 축으로 부드럽게 이동시킬 수 있습니다.

이제 이 도구들을 사용하여 실제 코드를 작성해 보겠습니다.

2. 단계별 구현: 스크롤 감지부터 애니메이션까지

가장 기본적인 형태부터 시작하여 점차 기능을 고도화하는 방식으로 진행하겠습니다. 먼저, 스크롤 가능한 화면과 BottomNavigationBar를 갖춘 기본 앱 구조를 만듭니다.

2.1. 프로젝트 기본 구조 설정

먼저, 상태를 관리해야 하므로 StatefulWidget으로 기본 페이지를 구성합니다. 이 페이지는 긴 목록을 가진 ListViewBottomNavigationBar를 포함합니다.


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Aware Bottom Bar'),
      ),
      body: ListView.builder(
        itemCount: 100, // 스크롤이 가능하도록 아이템 개수를 충분히 줍니다.
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
        currentIndex: _selectedIndex,
        onTap: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
      ),
    );
  }
}

위 코드는 아직 아무런 기능이 없는, 평범한 Flutter 앱의 모습입니다. 이제 여기에 스크롤 감지 로직을 추가해 보겠습니다.

2.2. 스크롤 감지하기: NotificationListener 활용

ScrollControllerListView에 직접 연결하고 리스너를 추가하는 방법도 있지만, NotificationListener를 사용하면 위젯 트리를 더 깔끔하게 유지할 수 있습니다. ListViewNotificationListener<UserScrollNotification> 위젯으로 감싸주기만 하면 됩니다. UserScrollNotification은 사용자의 직접적인 스크롤 액션에 의해서만 발생하는 알림이라, 코드에 의한 스크롤과 구분되어 더 정확한 제어가 가능합니다.

먼저, BottomNavigationBar의 가시성(visibility)을 제어할 상태 변수 _isVisible을 추가합니다.


// _HomePageState 클래스 내부에 추가
bool _isVisible = true;

다음으로, ListViewNotificationListener로 감싸고 onNotification 콜백을 구현합니다. 이 콜백 함수는 스크롤 이벤트가 발생할 때마다 호출됩니다.


// build 메소드 내부
// ...
body: NotificationListener<UserScrollNotification>(
  onNotification: (notification) {
    // 사용자가 아래로 스크롤 할 때 (리스트의 끝 방향)
    if (notification.direction == ScrollDirection.reverse) {
      if (_isVisible) {
        setState(() {
          _isVisible = false;
        });
      }
    }
    // 사용자가 위로 스크롤 할 때 (리스트의 시작 방향)
    else if (notification.direction == ScrollDirection.forward) {
      if (!_isVisible) {
        setState(() {
          _isVisible = true;
        });
      }
    }
    // true를 반환하면 상위로 알림이 전파되는 것을 막습니다.
    return true; 
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text('Item $index'),
      );
    },
  ),
),
// ...

이제 스크롤 방향에 따라 _isVisible 상태가 변경됩니다. 하지만 아직 UI에 아무런 변화가 없습니다. 이제 이 상태값을 이용하여 BottomNavigationBar를 실제로 움직여 보겠습니다.

2.3. 애니메이션으로 부드럽게 움직이기

_isVisible 상태가 바뀔 때마다 BottomNavigationBar가 부드럽게 나타나고 사라지게 하려면 애니메이션이 필요합니다. AnimationControllerAnimatedContainer 또는 Transform.translate를 사용할 수 있습니다. 여기서는 더 정교한 제어가 가능한 AnimationControllerTransform.translateAnimatedBuilder와 함께 사용하는 방법을 소개합니다. 이 조합은 성능적으로도 매우 효율적입니다.

2.3.1. AnimationController 초기화

_HomePageStateAnimationController를 추가하고 initState에서 초기화합니다. vsync를 사용해야 하므로 _HomePageStateTickerProviderStateMixin을 추가해야 합니다.


// 클래스 선언부 수정
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  // ... 기존 변수들

  late AnimationController _animationController;
  late Animation<Offset> _offsetAnimation;
  final double _bottomNavBarHeight = 85.0; // BottomNavBar의 높이, kBottomNavigationBarHeight + 여유분

  @override
  void initState() {
    super.initState();
    // 애니메이션 컨트롤러 초기화
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300), // 애니메이션 속도
    );

    // 오프셋 애니메이션 초기화
    // 시작(begin): Offset(0, 1) -> 화면 하단 밖
    // 끝(end): Offset.zero -> 화면 안 원래 위치
    _offsetAnimation = Tween<Offset>(
      begin: Offset.zero,
      end: Offset(0, 1), // Y축으로 자신의 높이만큼 아래로 이동
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
    
    // 처음에는 바가 보이도록 애니메이션을 완료(보이는 상태)로 설정
    _animationController.forward(); // 이 부분을 수정하여 시작 상태를 제어할 수 있습니다.
  }

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

  // ...
}

_animationController는 애니메이션의 '엔진'과 같습니다. duration을 설정하고 vsync를 연결하여 화면 주사율에 맞춰 부드러운 애니메이션을 생성합니다. _offsetAnimation은 컨트롤러의 값(0.0~1.0)을 실제 UI가 사용할 Offset 값으로 변환해주는 역할을 합니다. Offset(0, 1)은 Y축으로 위젯 자신의 높이의 1배만큼 아래로 이동하라는 의미입니다. (SlideTransition이 내부적으로 이렇게 계산합니다.)

2.3.2. 스크롤에 따른 애니메이션 트리거

이제 NotificationListener에서 setState를 호출하는 대신, _animationController를 제어합니다.


// onNotification 콜백 수정
onNotification: (notification) {
  if (notification.direction == ScrollDirection.reverse) {
    // 아래로 스크롤 -> 숨기기
    if (_animationController.isCompleted) { // 이미 보이는 상태일 때만 실행
        _animationController.reverse(); // 0.0으로 (숨겨지는 방향으로)
    }
  } else if (notification.direction == ScrollDirection.forward) {
    // 위로 스크롤 -> 보이기
    if (_animationController.isDismissed) { // 이미 숨겨진 상태일 때만 실행
        _animationController.forward(); // 1.0으로 (보이는 방향으로)
    }
  }
  return true;
},

코드를 약간 수정했습니다. _animationController.forward()는 애니메이션을 '끝' 상태(보이는 상태)로, reverse()는 '시작' 상태(숨겨진 상태)로 만듭니다. isCompletedisDismissed로 현재 애니메이션 상태를 확인하여 불필요한 호출을 막습니다.

2.3.3. SlideTransition으로 UI에 애니메이션 적용

마지막으로, BottomNavigationBarSlideTransition 위젯으로 감싸서 애니메이션을 실제로 UI에 반영합니다.


// build 메소드의 bottomNavigationBar 부분 수정
// ...
bottomNavigationBar: SlideTransition(
  position: _offsetAnimation,
  child: BottomNavigationBar(
    // ... 기존 BottomNavigationBar 코드
  ),
),

잠깐, 코드를 약간 수정해야겠습니다. Tween의 begin/end를 Offset(0, 0)(보임)과 Offset(0, 1)(숨김)으로 설정하고, 컨트롤러의 forward/reverse를 그에 맞게 다시 조정하는 것이 더 직관적입니다. 수정된 전체 코드를 보겠습니다.

3. 완성된 전체 코드 및 상세 설명

지금까지의 개념들을 종합하여 완성된, 즉시 실행 가능한 코드는 다음과 같습니다. 직관성을 위해 애니메이션 방향을 다시 정리했습니다.


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        scaffoldBackgroundColor: Colors.grey[200],
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  int _selectedIndex = 0;

  // 애니메이션 관련
  late final AnimationController _hideBottomBarAnimationController;

  // 가시성 상태를 직접 관리하는 변수
  bool _isBottomBarVisible = true;

  @override
  void initState() {
    super.initState();
    _hideBottomBarAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
      // 초기값: 1.0 (완전히 보이는 상태)
      value: 1.0, 
    );
  }

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

  // 스크롤 알림 처리 함수
  bool _handleScrollNotification(ScrollNotification notification) {
    // 사용자의 스크롤 액션일 때만 처리
    if (notification is UserScrollNotification) {
      final UserScrollNotification userScroll = notification;
      switch (userScroll.direction) {
        case ScrollDirection.forward:
          // 위로 스크롤: 바를 보여줌
          if (!_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = true;
              _hideBottomBarAnimationController.forward();
            });
          }
          break;
        case ScrollDirection.reverse:
          // 아래로 스크롤: 바를 숨김
          if (_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = false;
              _hideBottomBarAnimationController.reverse();
            });
          }
          break;
        case ScrollDirection.idle:
          // 스크롤 멈춤: 아무것도 안 함
          break;
      }
    }
    return false; // 다른 리스너도 알림을 받을 수 있도록 false 반환
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Perfect Scroll-Aware Bar'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: _handleScrollNotification,
        child: ListView.builder(
          // 스크롤 위치를 추후에 참조하기 위해 컨트롤러를 연결해둘 수 있습니다. (예외처리용)
          // controller: _scrollController, 
          itemCount: 100,
          itemBuilder: (context, index) {
            return Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: ListTile(
                leading: CircleAvatar(child: Text('$index')),
                title: Text('List Item $index'),
                subtitle: const Text('Scroll up and down to see the magic!'),
              ),
            );
          },
        ),
      ),
      // SizeTransition을 사용하여 높이를 조절하는 애니메이션을 구현
      bottomNavigationBar: SizeTransition(
        sizeFactor: _hideBottomBarAnimationController,
        axisAlignment: -1.0, // 바가 사라질 때 아래로 정렬되도록 함
        child: BottomNavigationBar(
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: 'Home',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.search),
              label: 'Search',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              label: 'Profile',
            ),
          ],
          currentIndex: _selectedIndex,
          onTap: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          selectedItemColor: Colors.indigo,
          unselectedItemColor: Colors.grey,
        ),
      ),
    );
  }
}

이전 예제에서 SlideTransition을 사용했지만, SizeTransition을 사용하는 것이 더 일반적이고 자연스러운 효과를 줍니다. SizeTransition은 자식 위젯의 높이(또는 너비)를 sizeFactor 값(0.0 ~ 1.0)에 따라 조절합니다. 애니메이션 컨트롤러의 값을 직접 sizeFactor에 연결하면, 컨트롤러 값이 1.0일 때 원래 높이를, 0.0일 때 높이 0을 갖게 되어 자연스럽게 사라지는 효과를 냅니다. axisAlignment: -1.0은 높이가 줄어들 때 위젯이 아래쪽 경계를 기준으로 축소되도록 하여, 마치 아래로 사라지는 것처럼 보이게 하는 중요한 속성입니다.

4. 심화 과정: 예외 처리 및 고급 기법

기본적인 기능은 완성되었습니다. 하지만 실제 프로덕션 환경에서는 다양한 예외 상황이 발생할 수 있습니다. 완성도를 높이기 위한 몇 가지 고급 기법들을 알아봅시다.

4.1. 스크롤 끝(Edge)에 도달했을 때 처리

사용자가 스크롤을 아주 빠르게 '휙' 던졌다가(Fling) 리스트의 맨 위나 맨 아래에 도달했을 때, 마지막 스크롤 방향이 reverse였다면 바가 숨겨진 채로 멈출 수 있습니다. 일반적으로 리스트의 끝에 도달했을 때는 탐색 바가 항상 보이는 것이 사용자 경험에 좋습니다.

이 문제를 해결하려면 ScrollController를 함께 사용해야 합니다. 컨트롤러를 ListView에 연결하고, 스크롤 알림 콜백에서 현재 스크롤 위치를 확인합니다.


// _HomePageState에 ScrollController 추가
final ScrollController _scrollController = ScrollController();

// initState에 리스너 추가 (또는 NotificationListener 내에서 확인)
@override
void initState() {
  super.initState();
  // ... 기존 코드
  _scrollController.addListener(_scrollListener);
}

void _scrollListener() {
    // 스크롤이 맨 위에 도달했을 때
    if (_scrollController.position.atEdge && _scrollController.position.pixels == 0) {
        if (!_isBottomBarVisible) {
            setState(() {
                _isBottomBarVisible = true;
                _hideBottomBarAnimationController.forward();
            });
        }
    }
}

// ListView에 controller 연결
// ...
child: ListView.builder(
  controller: _scrollController,
// ...

위 코드는 ScrollController의 리스너를 통해 스크롤 위치를 계속 감시합니다. position.atEdge가 true이고 position.pixels가 0이면 스크롤이 맨 위에 도달했음을 의미합니다. 이때 BottomNavigationBar를 강제로 보이게 합니다. NotificationListenerScrollController.addListener를 조합하여 더 정교한 제어가 가능해집니다.

4.2. 상태 관리 라이브러리(Provider)와 함께 사용하기

앱의 규모가 커지면 UI와 비즈니스 로직을 분리하는 것이 중요합니다. Provider나 Riverpod 같은 상태 관리 라이브러리를 사용하면 코드를 더 깔끔하게 구조화할 수 있습니다. BottomNavigationBar의 가시성 상태를 ChangeNotifier로 분리해 보겠습니다.

4.2.1. BottomBarVisibilityNotifier 생성


import 'package:flutter/material.dart';

class BottomBarVisibilityNotifier with ChangeNotifier {
  bool _isVisible = true;

  bool get isVisible => _isVisible;

  void show() {
    if (!_isVisible) {
      _isVisible = true;
      notifyListeners();
    }
  }

  void hide() {
    if (_isVisible) {
      _isVisible = false;
      notifyListeners();
    }
  }
}

4.2.2. Provider 설정 및 UI 연동

main.dart에서 ChangeNotifierProvider를 설정하고, UI에서는 Consumer 또는 context.watch를 사용하여 상태를 구독합니다.


// main.dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => BottomBarVisibilityNotifier(),
      child: const MyApp(),
    ),
  );
}

// HomePage.dart
// _handleScrollNotification 함수 내에서 setState 대신 Notifier 호출
// ...
if (userScroll.direction == ScrollDirection.forward) {
    context.read<BottomBarVisibilityNotifier>().show();
} else if (userScroll.direction == ScrollDirection.reverse) {
    context.read<BottomBarVisibilityNotifier>().hide();
}
// ...

// build 메소드 내에서
@override
Widget build(BuildContext context) {
    final isVisible = context.watch<BottomBarVisibilityNotifier>().isVisible;

    // isVisible 상태가 변경될 때마다 애니메이션 컨트롤러를 직접 제어
    // 이 로직은 build 메소드 내에 위치하면 안되므로, Consumer나 다른 방식을 사용해야 합니다.
    // 더 나은 방법은 Notifier 자체에 AnimationController를 포함시키는 것입니다.

    // ... 올바른 구현을 위해 Notifier가 AnimationController를 갖도록 리팩토링 ...
}

더 발전된 구조는 BottomBarVisibilityNotifierAnimationController를 직접 소유하고 관리하는 것입니다. 이렇게 하면 UI는 단순히 Notifier의 상태만 구독하고, 애니메이션 로직은 Notifier 내부에 완전히 캡슐화되어 재사용성이 극대화됩니다.

4.3. CustomScrollViewSliver 위젯과의 호환성

우리가 사용한 NotificationListener 방식의 가장 큰 장점은 특정 스크롤 위젯에 종속되지 않는다는 것입니다. ListView 대신 CustomScrollViewSliverAppBar, SliverList 등을 사용하는 복잡한 화면에서도 동일한 코드가 문제없이 작동합니다.


// body 부분을 CustomScrollView로 교체해도 동일하게 작동
body: NotificationListener<ScrollNotification>(
  onNotification: _handleScrollNotification,
  child: CustomScrollView(
    slivers: [
      SliverAppBar(
        title: Text('Complex Scroll'),
        floating: true,
        pinned: false,
      ),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            // ...
          ),
          childCount: 100,
        ),
      ),
    ],
  ),
),

CustomScrollView가 발생시키는 스크롤 알림 또한 NotificationListener가 감지할 수 있으므로, 어떤 종류의 스크롤 뷰를 사용하든 하단 바 숨김/표시 기능은 일관되게 동작합니다. 이것이 ScrollController에만 의존하는 방식보다 NotificationListener가 더 유연하고 강력한 이유입니다.

결론: 사용자 경험을 한 단계 끌어올리는 디테일

지금까지 Flutter에서 스크롤 방향에 따라 BottomNavigationBar를 동적으로 숨기고 표시하는 방법을 깊이 있게 탐구했습니다. 단순히 기능을 구현하는 것을 넘어, NotificationListener를 활용한 유연한 구조, AnimationControllerSizeTransition을 이용한 부드러운 애니메이션, 그리고 스크롤 끝에 도달하는 예외 상황 처리까지 다루었습니다.

이러한 동적 UI는 단순히 '있으면 좋은' 기능이 아니라, 사용자가 앱의 콘텐츠에 더 깊이 몰입하게 하고, 제한된 모바일 화면을 최대한 효율적으로 사용할 수 있게 해주는 핵심적인 UX 요소입니다. 오늘 배운 기법들을 여러분의 프로젝트에 적용하여, 사용자에게 더욱 쾌적하고 전문적인 인상을 주는 앱을 만들어 보시길 바랍니다.

핵심을 요약하자면 다음과 같습니다.

  • 스크롤 감지: NotificationListener<UserScrollNotification>을 사용하여 사용자의 명시적인 스크롤 의도를 파악합니다.
  • 상태 관리: bool 변수나 ChangeNotifier를 통해 바의 가시성 상태를 관리합니다.
  • 애니메이션: AnimationController를 상태에 따라 제어하고, SizeTransition 또는 SlideTransition을 사용하여 UI를 부드럽게 변경합니다.
  • 예외 처리: ScrollController를 보조적으로 사용하여 스크롤의 끝(edge)에 도달하는 등의 특수 상황에 대응하여 완성도를 높입니다.

이제 여러분은 Flutter에서 어떤 스크롤 뷰와도 완벽하게 연동되는 동적 BottomNavigationBar를 자신 있게 구현할 수 있을 것입니다. 코드를 직접 실행해보고, 애니메이션 속도나 커브를 변경해보며 자신만의 스타일을 찾아보는 것을 추천합니다.

Flutter's Disappearing BottomNavigationBar: The Definitive Guide for a Flawless UX

One of the most defining trends in modern mobile app User Experience (UX) is undoubtedly 'content-centric design.' The technique of dynamically hiding non-essential UI elements to allow users to focus on the content is no longer an option but a necessity. A prime example, commonly seen in apps like Instagram, Facebook, and modern web browsers, is the bottom tab bar (BottomNavigationBar) that disappears when scrolling down and reappears when scrolling up. This feature maximizes screen real estate and provides a much cleaner, more pleasant user experience.

If you're developing an app with Flutter, you've likely wondered how to implement such dynamic UI. It's not just about a binary 'show/hide' toggle; it's about creating a polished feature with smooth animations that accurately interprets the user's scroll intent. This article will provide a comprehensive, A-to-Z guide on implementing a 'scroll-aware bottom bar' that works perfectly in any complex scroll view. We will leverage Flutter's ScrollController, NotificationListener, and AnimationController. By the end, you won't just be copying and pasting code; you'll master the underlying principles and learn how to handle various edge cases.

1. Understanding the Core Principles: How Does It Work?

Before diving into the implementation, it's crucial to understand the core principles behind the feature we're building. The goal is simple: detect the user's scroll direction and, based on that direction, either push the BottomNavigationBar off-screen or bring it back into view.

  1. Detect Scroll Direction: We need to know if the user is swiping their finger up (scrolling the content down) or pulling their finger down (scrolling the content up).
  2. Modify UI Position: Based on the detected direction, we will move the BottomNavigationBar along the Y-axis. When scrolling down, we'll move it down by its own height to hide it off-screen. When scrolling up, we'll return it to its original position (Y=0).
  3. Apply a Smooth Transition: An instantaneous change in position feels jarring to the user. Therefore, we must apply an animation to make the bar slide smoothly in and out of view.

To implement these three principles, Flutter provides a set of powerful tools:

  • ScrollController or NotificationListener: These are used to listen for scroll events from scrollable widgets like ListView, GridView, or CustomScrollView. While ScrollController allows for direct control over the scroll position, NotificationListener can listen for various notifications from child scroll widgets higher up the widget tree. We will explore both but focus on implementing the more flexible NotificationListener approach.
  • userScrollDirection: This is a property of the ScrollPosition object that indicates the user's current scroll direction as one of three states: ScrollDirection.forward (scrolling up), ScrollDirection.reverse (scrolling down), and ScrollDirection.idle (stopped).
  • AnimationController and Transform.translate: An AnimationController manages the progress of an animation (from 0.0 to 1.0) over a specific duration. By using its value to control the offset of a Transform.translate widget, we can smoothly move any widget along a desired axis.

Now, let's use these tools to write the actual code.

2. Step-by-Step Implementation: From Scroll Detection to Animation

We'll start with the most basic form and gradually enhance its functionality. First, let's create a basic app structure with a scrollable screen and a BottomNavigationBar.

2.1. Basic Project Setup

Since we need to manage state, we'll start with a StatefulWidget for our main page. This page will contain a ListView with a long list of items and a BottomNavigationBar.


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Aware Bottom Bar'),
      ),
      body: ListView.builder(
        itemCount: 100, // Provide enough items to make the list scrollable
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
        currentIndex: _selectedIndex,
        onTap: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
      ),
    );
  }
}

The code above is a standard, plain Flutter app with no special functionality yet. Now, let's add the scroll detection logic.

2.2. Detecting the Scroll: Utilizing NotificationListener

While you could attach a ScrollController directly to the ListView and add a listener, using a NotificationListener can help keep the widget tree cleaner. You simply wrap the ListView with a NotificationListener<UserScrollNotification> widget. UserScrollNotification is particularly useful because it's only dispatched in response to a user's direct scroll action, allowing you to distinguish it from programmatic scrolling for more precise control.

First, let's add a state variable _isVisible to control the visibility of the BottomNavigationBar.


// Add inside the _HomePageState class
bool _isVisible = true;

Next, wrap the ListView with a NotificationListener and implement the onNotification callback. This callback function will be invoked every time a scroll event occurs.


// Inside the build method
// ...
body: NotificationListener<UserScrollNotification>(
  onNotification: (notification) {
    // When the user scrolls down (towards the end of the list)
    if (notification.direction == ScrollDirection.reverse) {
      if (_isVisible) {
        setState(() {
          _isVisible = false;
        });
      }
    }
    // When the user scrolls up (towards the start of the list)
    else if (notification.direction == ScrollDirection.forward) {
      if (!_isVisible) {
        setState(() {
          _isVisible = true;
        });
      }
    }
    // Return true to prevent the notification from bubbling up.
    return true; 
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text('Item $index'),
      );
    },
  ),
),
// ...

Now, the _isVisible state changes based on the scroll direction. However, there's no visible change in the UI yet. Let's use this state variable to actually move the BottomNavigationBar.

2.3. Smooth Movement with Animations

To make the BottomNavigationBar appear and disappear smoothly whenever the _isVisible state changes, we need animations. We can use AnimationController with either AnimatedContainer or Transform.translate. Here, we'll introduce the method of using AnimationController and Transform.translate with AnimatedBuilder, which is more powerful and efficient.

2.3.1. Initializing the AnimationController

Add an AnimationController to _HomePageState and initialize it in initState. Since this requires a vsync, we must add the TickerProviderStateMixin to the _HomePageState class.


// Modify the class declaration
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  // ... existing variables

  late AnimationController _animationController;
  late Animation<Offset> _offsetAnimation;

  @override
  void initState() {
    super.initState();
    // Initialize the animation controller
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300), // Animation speed
    );

    // Initialize the offset animation
    // begin: Offset.zero -> In its original position inside the screen
    // end: Offset(0, 1) -> Moved down by its own height, outside the screen
    _offsetAnimation = Tween<Offset>(
      begin: Offset.zero,
      end: const Offset(0, 1),
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
  }

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

  // ...
}

The _animationController acts as the "engine" for our animation. We set its duration and link it to a vsync to create smooth animations synchronized with the screen's refresh rate. The _offsetAnimation is a `Tween` that translates the controller's value (0.0 to 1.0) into an Offset value that the UI can use. An Offset(0, 1) tells a widget to move down along the Y-axis by 1x its own height. (This is how `SlideTransition` works internally.)

2.3.2. Triggering the Animation on Scroll

Now, instead of calling setState in our NotificationListener, we'll control the _animationController.


// Modify the onNotification callback
onNotification: (notification) {
  if (notification.direction == ScrollDirection.reverse) {
    // Scrolling down -> Hide the bar
    _animationController.forward(); // Animates towards the 'end' state (hidden)
  } else if (notification.direction == ScrollDirection.forward) {
    // Scrolling up -> Show the bar
    _animationController.reverse(); // Animates towards the 'begin' state (visible)
  }
  return true;
},

Here, _animationController.forward() drives the animation from its beginning to its end (making the bar disappear), while reverse() does the opposite. We can add checks like _animationController.isCompleted or isDismissed to prevent redundant calls.

2.3.3. Applying the Animation to the UI with SlideTransition

Finally, we wrap our BottomNavigationBar with a SlideTransition widget to apply the animation to the UI.


// Modify the bottomNavigationBar part of the build method
// ...
bottomNavigationBar: SlideTransition(
  position: _offsetAnimation,
  child: BottomNavigationBar(
    // ... existing BottomNavigationBar code
  ),
),

Let's refine this. To make it more intuitive, let's set the `begin` and `end` of the `Tween` to be `Offset(0, 0)` (visible) and `Offset(0, 1)` (hidden), and then adjust the controller's forward/reverse logic accordingly. Let's see the complete, polished code.

3. The Complete, Polished Code and Detailed Explanation

Combining all the concepts we've discussed, here is the complete, ready-to-run code. For better intuition and a more natural effect, we've switched to using SizeTransition.


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        scaffoldBackgroundColor: Colors.grey[200],
      ),
      debugShowCheckedModeBanner: false,
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  int _selectedIndex = 0;

  // Animation controller for the bottom bar
  late final AnimationController _hideBottomBarAnimationController;

  // A direct state variable to manage visibility
  bool _isBottomBarVisible = true;

  @override
  void initState() {
    super.initState();
    _hideBottomBarAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
      // Initial value: 1.0 (fully visible)
      value: 1.0, 
    );
  }

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

  // Scroll notification handler function
  bool _handleScrollNotification(ScrollNotification notification) {
    // We only care about user-driven scrolls
    if (notification is UserScrollNotification) {
      final UserScrollNotification userScroll = notification;
      switch (userScroll.direction) {
        case ScrollDirection.forward:
          // Scrolling up: show the bar
          if (!_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = true;
              _hideBottomBarAnimationController.forward();
            });
          }
          break;
        case ScrollDirection.reverse:
          // Scrolling down: hide the bar
          if (_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = false;
              _hideBottomBarAnimationController.reverse();
            });
          }
          break;
        case ScrollDirection.idle:
          // Scroll has stopped: do nothing
          break;
      }
    }
    return false; // Return false to allow other listeners to receive the notification
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Perfect Scroll-Aware Bar'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: _handleScrollNotification,
        child: ListView.builder(
          // A controller can be attached for future use (e.g., edge cases)
          // controller: _scrollController, 
          itemCount: 100,
          itemBuilder: (context, index) {
            return Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: ListTile(
                leading: CircleAvatar(child: Text('$index')),
                title: Text('List Item $index'),
                subtitle: const Text('Scroll up and down to see the magic!'),
              ),
            );
          },
        ),
      ),
      // Use SizeTransition to animate the height
      bottomNavigationBar: SizeTransition(
        sizeFactor: _hideBottomBarAnimationController,
        axisAlignment: -1.0, // Aligns the child to the bottom as it shrinks
        child: BottomNavigationBar(
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: 'Home',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.search),
              label: 'Search',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              label: 'Profile',
            ),
          ],
          currentIndex: _selectedIndex,
          onTap: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          selectedItemColor: Colors.indigo,
          unselectedItemColor: Colors.grey,
        ),
      ),
    );
  }
}

While we used SlideTransition in the previous example, using SizeTransition often provides a more common and natural-looking effect. SizeTransition animates the height (or width) of its child based on the sizeFactor value (from 0.0 to 1.0). By directly connecting our animation controller to the sizeFactor, the bar will have its full height when the controller's value is 1.0 and a height of 0 when it's 0.0, creating a natural disappearing effect. The property axisAlignment: -1.0 is crucial here; it ensures that as the height decreases, the widget shrinks towards its bottom edge, making it look as if it's sliding down and away.

4. Advanced Topics: Edge Cases and Best Practices

The basic functionality is now complete. However, in a real production environment, various edge cases can arise. Let's explore a few advanced techniques to increase the robustness of our feature.

4.1. Handling Reaching the Scroll Edge

If a user "flings" the scroll very fast and hits the top or bottom of the list, the last scroll direction might have been reverse, leaving the bar hidden. Generally, it's better for the user experience if the navigation bar is always visible when the user is at the very top of the list.

To solve this, we can use a ScrollController in conjunction with our NotificationListener. Attach a controller to the ListView and check the scroll position within the notification callback or a separate listener.


// Add a ScrollController to _HomePageState
final ScrollController _scrollController = ScrollController();

// In initState, add a listener (or check within the NotificationListener)
@override
void initState() {
  super.initState();
  // ... existing code
  _scrollController.addListener(_scrollListener);
}

void _scrollListener() {
    // When the scroll position is at the top edge
    if (_scrollController.position.atEdge && _scrollController.position.pixels == 0) {
        if (!_isBottomBarVisible) {
            setState(() {
                _isBottomBarVisible = true;
                _hideBottomBarAnimationController.forward();
            });
        }
    }
}

// Attach the controller to the ListView
// ...
child: ListView.builder(
  controller: _scrollController,
// ...

The code above uses a listener on the ScrollController to continuously monitor the scroll position. If position.atEdge is true and position.pixels is 0, it means we've reached the very top of the scroll view. At this point, we forcibly show the BottomNavigationBar. Combining NotificationListener and ScrollController.addListener allows for more sophisticated control.

4.2. Integrating with a State Management Library (e.g., Provider)

As your app grows, separating UI from business logic becomes critical. Using a state management library like Provider or Riverpod helps structure your code more cleanly. Let's refactor the BottomNavigationBar's visibility state into a ChangeNotifier.

4.2.1. Create a BottomBarVisibilityNotifier


import 'package:flutter/material.dart';

class BottomBarVisibilityNotifier with ChangeNotifier {
  bool _isVisible = true;

  bool get isVisible => _isVisible;

  void show() {
    if (!_isVisible) {
      _isVisible = true;
      notifyListeners();
    }
  }

  void hide() {
    if (_isVisible) {
      _isVisible = false;
      notifyListeners();
    }
  }
}

4.2.2. Configure Provider and Connect to the UI

Set up a ChangeNotifierProvider in your `main.dart` and use a Consumer or `context.watch` in the UI to subscribe to state changes.


// main.dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => BottomBarVisibilityNotifier(),
      child: const MyApp(),
    ),
  );
}

// HomePage.dart
// Inside the _handleScrollNotification function, call the Notifier instead of setState
// ...
if (userScroll.direction == ScrollDirection.forward) {
    context.read<BottomBarVisibilityNotifier>().show();
} else if (userScroll.direction == ScrollDirection.reverse) {
    context.read<BottomBarVisibilityNotifier>().hide();
}
// ...

// Inside the build method
@override
Widget build(BuildContext context) {
    // This is a naive implementation; a better way is needed to trigger the animation.
    // A Listener or Consumer is better suited.
    // final isVisible = context.watch<BottomBarVisibilityNotifier>().isVisible;
    // ...
    // A superior approach is to have the Notifier itself manage the AnimationController.
}

An even more advanced and cleaner architecture is for the BottomBarVisibilityNotifier to own and manage the AnimationController itself. This way, the UI widgets simply subscribe to the Notifier's state, and the animation logic is fully encapsulated within the Notifier, maximizing reusability and separation of concerns.

4.3. Compatibility with CustomScrollView and Sliver Widgets

The greatest advantage of our NotificationListener approach is its independence from any specific scroll widget. The same code will work flawlessly on a more complex screen that uses CustomScrollView with SliverAppBar, SliverList, and other slivers.


// The body can be replaced with a CustomScrollView and it will still work
body: NotificationListener<ScrollNotification>(
  onNotification: _handleScrollNotification,
  child: CustomScrollView(
    slivers: [
      const SliverAppBar(
        title: Text('Complex Scroll'),
        floating: true,
        pinned: false,
      ),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            // ...
          ),
          childCount: 100,
        ),
      ),
    ],
  ),
),

Because the NotificationListener can capture scroll notifications bubbling up from a CustomScrollView just as easily as from a ListView, our hide/show functionality remains consistent. This is what makes the NotificationListener approach more flexible and powerful than relying solely on a ScrollController.

Conclusion: The Details That Elevate User Experience

We have taken a deep dive into how to dynamically hide and show a BottomNavigationBar in Flutter based on the scroll direction. We've gone beyond a simple implementation to cover a flexible architecture using NotificationListener, smooth animations with AnimationController and SizeTransition, and even handling edge cases like reaching the end of a scroll view.

This kind of dynamic UI is not just a "nice-to-have" feature; it is a core UX element that allows users to immerse themselves more deeply in the app's content and makes the most efficient use of limited mobile screen space. We encourage you to apply the techniques you've learned today to your own projects to build apps that feel more professional and delightful to use.

Here are the key takeaways:

  • Scroll Detection: Use NotificationListener<UserScrollNotification> to capture the user's explicit scroll intent.
  • State Management: Manage the bar's visibility state with a simple bool variable or a more robust ChangeNotifier.
  • Animation: Control an AnimationController based on the state, and use SizeTransition or SlideTransition to smoothly update the UI.
  • Edge Case Handling: Use a ScrollController as a supplementary tool to handle special cases like reaching the scroll edges, thereby perfecting the implementation.

You should now be able to confidently implement a dynamic BottomNavigationBar that integrates perfectly with any scroll view in Flutter. We recommend you run the code yourself, experiment with different animation durations and curves, and find the style that best fits your app.

Flutterスクロール連動BottomNavigationBar実装、ユーザー体験を劇的に向上させるテクニック

現代のモバイルアプリにおけるユーザー体験(UX)のデザイントレンドとして、間違いなく「コンテンツ中心設計」が挙げられます。ユーザーが画面のコンテンツに最大限集中できるよう、不要なUI要素を動的に隠す技術は、もはや選択肢ではなく必須要件となっています。特に、InstagramやFacebook、最新のウェブブラウザなどでよく見られる、下にスクロールすると下部のタブバー(BottomNavigationBar)が消え、上にスクロールすると再び表示される機能は、画面スペースを最大化し、ユーザーに快適な体験を提供します。

Flutterでアプリを開発する中で、このような動的なUIをどのように実装すればよいか悩んだことがあるでしょう。単に「表示/非表示」を切り替えるだけでなく、スムーズなアニメーションを伴い、ユーザーのスクロールの意図を正確に読み取って反応する、完成度の高いBottomNavigationBarを実装することが重要です。この記事では、FlutterのScrollControllerNotificationListener、そしてAnimationControllerを組み合わせ、どんなに複雑なスクロールビューでも完璧に動作する「スクロール連動型ボトムバー」を実装する全プロセスを、AからZまで詳細に解説します。単にコードをコピー&ペーストするだけでなく、その背後にある原理を理解し、様々な例外状況に対応する方法までマスターすることができます。

1. 基本原則の理解:どのように動作するのか?

実装に入る前に、これから作成する機能の核心となる原理を理解することが重要です。目標はシンプルです。ユーザーのスクロール方向を検知し、その方向に応じてBottomNavigationBarの位置を画面外に押し出したり、再び画面内に戻したりすることです。

  1. スクロール方向の検知:ユーザーが指で画面を上にスワイプしているか(コンテンツを下にスクロール中)、下にスワイプしているか(コンテンツを上にスクロール中)を把握する必要があります。
  2. UI位置の変更:検知した方向に応じて、BottomNavigationBarをY軸方向に移動させます。下にスクロールする際は、バーの高さ分だけ下に移動させて画面外に隠し、上にスクロールする際は、再び元の位置(Y=0)に戻します。
  3. スムーズなトランジション効果:位置が瞬間的に変化すると、ユーザーは不自然さを感じます。そのため、アニメーションを適用して、バーが滑らかにスライドするように見せる必要があります。

これら3つの原則を実装するために、Flutterは次のような強力なツールを提供しています。

  • ScrollController または NotificationListenerListViewGridViewCustomScrollViewなどのスクロール可能なウィジェットのスクロールイベントを検知する役割を担います。特にScrollControllerはスクロール位置を直接制御でき、NotificationListenerはウィジェットツリーの上位で子ウィジェットからの様々な通知(Notification)を受け取ることができます。本記事では両方について触れますが、より柔軟なNotificationListenerを中心に実装を進めます。
  • userScrollDirectionScrollPositionオブジェクトに含まれるプロパティで、ユーザーの現在のスクロール方向をScrollDirection.forward(上スクロール)、ScrollDirection.reverse(下スクロール)、ScrollDirection.idle(停止)の3つの状態で知らせてくれます。
  • AnimationControllerTransform.translate (またはSizeTransition):AnimationControllerは、アニメーションの進行状態(0.0から1.0)を特定の時間で管理するコントローラーです。この値を利用してTransform.translateウィジェットのoffsetSizeTransitionsizeFactorを調整することで、どんなウィジェットでも好きな軸方向にスムーズに移動させたり、サイズを変更したりできます。

それでは、これらのツールを使って実際にコードを書いていきましょう。

2. ステップ・バイ・ステップ実装:スクロール検知からアニメーションまで

最も基本的な形から始め、徐々に機能を高度化していく方式で進めます。まず、スクロール可能な画面とBottomNavigationBarを備えた基本的なアプリ構造を作成します。

2.1. プロジェクトの基本構造設定

状態を管理する必要があるため、StatefulWidgetで基本ページを構成します。このページは、長いリストを持つListViewBottomNavigationBarを含みます。


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Aware Bottom Bar'),
      ),
      body: ListView.builder(
        // スクロール可能にするために十分な数のアイテムを用意します
        itemCount: 100,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
        currentIndex: _selectedIndex,
        onTap: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
      ),
    );
  }
}

上記のコードは、まだ何の機能もない、ごく普通のFlutterアプリの姿です。ここにスクロール検知ロジックを追加していきましょう。

2.2. スクロールの検知:NotificationListenerの活用

ScrollControllerListViewに直接接続してリスナーを追加する方法もありますが、NotificationListenerを使用するとウィジェットツリーをよりクリーンに保つことができます。ListViewNotificationListener<UserScrollNotification>ウィジェットでラップするだけです。UserScrollNotificationは、ユーザーの直接的なスクロール操作によってのみ発生する通知なので、コードによるスクロールと区別でき、より正確な制御が可能です。

まず、BottomNavigationBarの可視性(visibility)を制御するための状態変数_isVisibleを追加します。


// _HomePageStateクラス内に追加
bool _isVisible = true;

次に、ListViewNotificationListenerでラップし、onNotificationコールバックを実装します。このコールバック関数は、スクロールイベントが発生するたびに呼び出されます。


// buildメソッド内
// ...
body: NotificationListener<UserScrollNotification>(
  onNotification: (notification) {
    // ユーザーが下にスクロールした時(リストの終端方向)
    if (notification.direction == ScrollDirection.reverse) {
      if (_isVisible) {
        setState(() {
          _isVisible = false;
        });
      }
    }
    // ユーザーが上にスクロールした時(リストの始端方向)
    else if (notification.direction == ScrollDirection.forward) {
      if (!_isVisible) {
        setState(() {
          _isVisible = true;
        });
      }
    }
    // trueを返すと、通知が上位に伝播するのを防ぎます
    return true; 
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text('Item $index'),
      );
    },
  ),
),
// ...

これでスクロール方向に応じて_isVisibleの状態が変更されるようになりました。しかし、まだUIには何の変化もありません。この状態変数を使って、実際にBottomNavigationBarを動かしてみましょう。

2.3. アニメーションでスムーズに動かす

_isVisibleの状態が変わるたびにBottomNavigationBarが滑らかに表示・非表示されるようにするには、アニメーションが必要です。ここでは、より精密な制御が可能でパフォーマンスも高いAnimationControllerSizeTransitionを組み合わせて使用する方法を紹介します。

2.3.1. AnimationControllerの初期化

_HomePageStateAnimationControllerを追加し、initStateで初期化します。vsyncを使用する必要があるため、_HomePageStateTickerProviderStateMixinを追加する必要があります。


// クラス宣言部分を修正
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  // ... 既存の変数

  late AnimationController _animationController;

  @override
  void initState() {
    super.initState();
    // アニメーションコントローラーの初期化
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300), // アニメーションの速度
      value: 1.0, // 初期値は1.0(完全に見える状態)
    );
  }

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

  // ...
}

_animationControllerはアニメーションの「エンジン」のようなものです。durationを設定し、vsyncを連携させることで、画面のリフレッシュレートに合わせた滑らかなアニメーションを生成します。

2.3.2. スクロールに応じたアニメーショントリガー

NotificationListener内でsetStateを呼び出す代わりに、_animationControllerを制御します。


// onNotificationコールバックを修正
onNotification: (notification) {
  if (notification.direction == ScrollDirection.reverse) {
    // 下スクロール -> 隠す
    if (_animationController.isCompleted) { // すでに表示状態の場合のみ実行
        _animationController.reverse(); // 0.0へ(隠れる方向へ)
    }
  } else if (notification.direction == ScrollDirection.forward) {
    // 上スクロール -> 見せる
    if (_animationController.isDismissed) { // すでに隠れている状態の場合のみ実行
        _animationController.forward(); // 1.0へ(見える方向へ)
    }
  }
  return true;
},

_animationController.forward()はアニメーションを「完了」状態(見える状態)にし、reverse()は「開始」状態(隠れる状態)にします。isCompletedisDismissedで現在のアニメーション状態を確認し、不要な呼び出しを防ぎます。

2.3.3. SizeTransitionでUIにアニメーションを適用

最後に、BottomNavigationBarSizeTransitionウィジェットでラップし、アニメーションを実際にUIに反映させます。


// buildメソッドのbottomNavigationBar部分を修正
// ...
bottomNavigationBar: SizeTransition(
  sizeFactor: _animationController,
  axisAlignment: -1.0,
  child: BottomNavigationBar(
    // ... 既存のBottomNavigationBarコード
  ),
),

これで、アニメーションコントローラーの値が1.0から0.0に変化するにつれて、BottomNavigationBarの高さが滑らかに0になり、画面下部に消えていくように見えます。axisAlignment: -1.0は、高さが縮小する際にウィジェットが下端を基準に縮小されるようにするための重要なプロパティです。

3. 完成版コードと詳細解説

これまでの概念を統合した、すぐに実行可能な完成版コードは以下の通りです。ロジックをより明確にするために、状態管理の方法を少し調整しました。


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        scaffoldBackgroundColor: Colors.grey[200],
      ),
      debugShowCheckedModeBanner: false,
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  int _selectedIndex = 0;

  // アニメーション関連
  late final AnimationController _hideBottomBarAnimationController;

  // 可視性を直接管理する状態変数
  bool _isBottomBarVisible = true;

  @override
  void initState() {
    super.initState();
    _hideBottomBarAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
      // 初期値: 1.0 (完全に見える状態)
      value: 1.0, 
    );
  }

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

  // スクロール通知処理関数
  bool _handleScrollNotification(ScrollNotification notification) {
    // ユーザーのスクロール操作の場合のみ処理
    if (notification is UserScrollNotification) {
      final UserScrollNotification userScroll = notification;
      switch (userScroll.direction) {
        case ScrollDirection.forward:
          // 上スクロール: バーを表示
          if (!_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = true;
              _hideBottomBarAnimationController.forward();
            });
          }
          break;
        case ScrollDirection.reverse:
          // 下スクロール: バーを隠す
          if (_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = false;
              _hideBottomBarAnimationController.reverse();
            });
          }
          break;
        case ScrollDirection.idle:
          // スクロール停止: 何もしない
          break;
      }
    }
    // falseを返して、他のリスナーも通知を受け取れるようにする
    return false;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Perfect Scroll-Aware Bar'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: _handleScrollNotification,
        child: ListView.builder(
          // 後で参照するためにコントローラーを接続しておくことも可能(例外処理用)
          // controller: _scrollController, 
          itemCount: 100,
          itemBuilder: (context, index) {
            return Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: ListTile(
                leading: CircleAvatar(child: Text('$index')),
                title: Text('List Item $index'),
                subtitle: const Text('Scroll up and down to see the magic!'),
              ),
            );
          },
        ),
      ),
      // SizeTransitionを使用して高さを調整するアニメーションを実装
      bottomNavigationBar: SizeTransition(
        sizeFactor: _hideBottomBarAnimationController,
        // バーが消えるときに下揃えになるようにする
        axisAlignment: -1.0, 
        child: BottomNavigationBar(
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: 'Home',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.search),
              label: 'Search',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              label: 'Profile',
            ),
          ],
          currentIndex: _selectedIndex,
          onTap: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          selectedItemColor: Colors.indigo,
          unselectedItemColor: Colors.grey,
        ),
      ),
    );
  }
}

この完成版コードでは、_isBottomBarVisibleというbool値で可視性の状態を明確に管理し、状態が変化したときにのみアニメーションをトリガーするようにしています。これにより、不要なアニメーションの呼び出しを防ぎ、より安定した動作を実現します。

4. 応用編:エッジケース対応と高度なテクニック

基本的な機能は完成しました。しかし、実際のプロダクション環境では様々な例外状況が発生する可能性があります。完成度をさらに高めるためのいくつかの高度なテクニックを見ていきましょう。

4.1. スクロール終端(Edge)に到達した時の処理

ユーザーがスクロールを非常に速く「フリック」してリストの最上部や最下部に到達した際、最後のスクロール方向がreverseだった場合、バーが隠れたままになってしまうことがあります。一般的に、リストの最上部にいるときはナビゲーションバーが常に表示されている方がユーザー体験として優れています。

この問題を解決するには、ScrollControllerを併用します。コントローラーをListViewに接続し、スクロール通知コールバック内で現在のスクロール位置を確認します。


// _HomePageStateにScrollControllerを追加
final ScrollController _scrollController = ScrollController();

// initStateにリスナーを追加 (またはNotificationListener内で確認)
@override
void initState() {
  super.initState();
  // ... 既存のコード
  _scrollController.addListener(_scrollListener);
}

void _scrollListener() {
    // スクロールが最上部に到達した時
    if (_scrollController.position.atEdge && _scrollController.position.pixels == 0) {
        if (!_isBottomBarVisible) {
            setState(() {
                _isBottomBarVisible = true;
                _hideBottomBarAnimationController.forward();
            });
        }
    }
}

// ListViewにcontrollerを接続
// ...
child: ListView.builder(
  controller: _scrollController,
// ...

上記のコードは、ScrollControllerのリスナーを通じてスクロール位置を常に監視します。position.atEdgeがtrueかつposition.pixelsが0であれば、スクロールが最上部に到達したことを意味します。この時にBottomNavigationBarを強制的に表示させます。NotificationListenerScrollController.addListenerを組み合わせることで、より精密な制御が可能になります。

4.2. 状態管理ライブラリ(Providerなど)との連携

アプリの規模が大きくなると、UIとビジネスロジックを分離することが重要になります。ProviderやRiverpodのような状態管理ライブラリを使用すると、コードをよりクリーンに構造化できます。BottomNavigationBarの可視性状態をChangeNotifierで分離してみましょう。

4.2.1. BottomBarVisibilityNotifierの作成


import 'package:flutter/material.dart';

class BottomBarVisibilityNotifier with ChangeNotifier {
  bool _isVisible = true;

  bool get isVisible => _isVisible;

  void show() {
    if (!_isVisible) {
      _isVisible = true;
      notifyListeners();
    }
  }

  void hide() {
    if (_isVisible) {
      _isVisible = false;
      notifyListeners();
    }
  }
}

4.2.2. Providerの設定とUIの連携

main.dartChangeNotifierProviderを設定し、UIではConsumercontext.watchを使用して状態を購読します。これにより、UIとロジックが疎結合になり、再利用性とテスト容易性が向上します。

4.3. CustomScrollViewSliverウィジェットとの互換性

私たちが採用したNotificationListener方式の最大の利点は、特定のスクロールウィジェットに依存しないことです。ListViewの代わりにCustomScrollViewSliverAppBarSliverListなどを使用する複雑な画面でも、同じコードが問題なく動作します。


// body部分をCustomScrollViewに置き換えても同様に動作
body: NotificationListener<ScrollNotification>(
  onNotification: _handleScrollNotification,
  child: CustomScrollView(
    slivers: [
      SliverAppBar(
        title: Text('Complex Scroll'),
        floating: true,
        pinned: false,
      ),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            // ...
          ),
          childCount: 100,
        ),
      ),
    ],
  ),
),

CustomScrollViewが発生させるスクロール通知もNotificationListenerが検知できるため、どのような種類のスクロールビューを使用しても、ボトムバーの表示/非表示機能は一貫して動作します。これが、ScrollControllerだけに依存する方式よりもNotificationListenerがより柔軟で強力な理由です。

結論:ユーザー体験を一段階引き上げるディテール

ここまで、Flutterでスクロール方向に応じてBottomNavigationBarを動的に表示・非表示する方法を深く探求してきました。単に機能を実装するだけでなく、NotificationListenerを活用した柔軟な構造、AnimationControllerSizeTransitionを利用した滑らかなアニメーション、そしてスクロール終端に到達する例外状況の処理までをカバーしました。

このような動的なUIは、単に「あれば良い」機能ではなく、ユーザーがアプリのコンテンツにより深く没入し、限られたモバイルの画面を最大限効率的に使えるようにするための、核心的なUX要素です。今日学んだ技術をあなたのプロジェクトに適用し、ユーザーにとってより快適でプロフェッショナルな印象を与えるアプリを開発してください。

要点をまとめると以下のようになります。

  • スクロール検知:NotificationListener<UserScrollNotification>を使用して、ユーザーの明確なスクロール意図を把握します。
  • 状態管理:bool変数やChangeNotifierを通じて、バーの可視性状態を管理します。
  • アニメーション:状態に応じてAnimationControllerを制御し、SizeTransitionSlideTransitionを使用してUIを滑らかに変化させます。
  • 例外処理:ScrollControllerを補助的に使用し、スクロールの終端(edge)に到達するなどの特殊な状況に対応して完成度を高めます。

これであなたは、Flutterであらゆるスクロールビューと完璧に連動する動的なBottomNavigationBarを自信を持って実装できるようになったはずです。ぜひコードを実際に動かし、アニメーションの速度やカーブを変更してみて、自分だけのスタイルを見つけてみることをお勧めします。

完美实现!Flutter中随滚动丝滑消失的底部导航栏

在现代移动应用的用户体验(UX)设计中,“以内容为中心”无疑是最重要的趋势之一。为了让用户能够最大限度地专注于屏幕上的内容,动态隐藏非核心UI元素的技术已经不再是可选项,而是必需品。一个典型的例子,常见于Instagram、Facebook和现代网页浏览器中,就是那个随着向下滚动而消失、向上滚动时又重新出现的底部标签栏(BottomNavigationBar)。这个功能极大地扩展了屏幕的有效空间,为用户提供了更清爽、更愉悦的体验。

如果您正在使用Flutter开发应用,很可能也思考过如何实现这种动态UI。这不仅仅是一个简单的“显示/隐藏”切换,关键在于创建一种带有平滑动画、能够精确解读用户滚动意图并作出响应的、高完成度的功能。本文将提供一份从A到Z的详尽指南,教您如何结合使用Flutter的ScrollControllerNotificationListenerAnimationController,实现一个在任何复杂滚动视图中都能完美工作的“滚动感知型底部导航栏”。读完本文,您将不仅仅是复制代码,而是能真正掌握其底层原理,并学会处理各种边界情况。

1. 理解核心原理:它是如何工作的?

在投入编码之前,理解我们所构建功能的核心原理至关重要。目标很简单:检测用户的滚动方向,并根据该方向将BottomNavigationBar推到屏幕外或拉回视野内。

  1. 侦测滚动方向:我们需要知道用户是向上滑动手指(内容向下滚动)还是向下滑动手指(内容向上滚动)。
  2. 修改UI位置:根据侦测到的方向,我们将沿着Y轴移动BottomNavigationBar。向下滚动时,我们将其向下移动自身的高度,以将其隐藏在屏幕之外。向上滚动时,我们将其恢复到原始位置(Y=0)。
  3. 应用平滑过渡:位置的瞬时变化会让用户感到突兀。因此,我们必须应用动画,使导航栏平滑地滑入和滑出。

为了实现这三个原则,Flutter提供了一套强大的工具:

  • ScrollControllerNotificationListener这些工具用于监听可滚动组件(如ListView, GridView, CustomScrollView等)的滚动事件。ScrollController允许直接控制滚动位置,而NotificationListener可以在组件树的更高层级监听子滚动组件发出的各种通知(Notification)。我们将探讨这两种方法,但会重点使用更灵活的NotificationListener方案。
  • userScrollDirection这是ScrollPosition对象的一个属性,它以三种状态指示用户的当前滚动方向:ScrollDirection.forward(向上滚动)、ScrollDirection.reverse(向下滚动)和ScrollDirection.idle(静止)。
  • AnimationControllerTransform.translate/SizeTransitionAnimationController用于在指定时间内管理动画的进度(从0.0到1.0)。通过使用它的值来控制Transform.translate组件的offsetSizeTransitionsizeFactor,我们可以平滑地沿所需轴移动任何组件或改变其尺寸。

现在,让我们使用这些工具来编写实际的代码。

2. 分步实现:从滚动检测到动画效果

我们将从最基本的形式开始,逐步增强其功能。首先,让我们创建一个包含可滚动屏幕和BottomNavigationBar的基本应用结构。

2.1. 项目基础结构设置

由于需要管理状态,我们将使用StatefulWidget来构建主页面。该页面将包含一个带有长列表的ListView和一个BottomNavigationBar


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scroll Aware Bottom Bar'),
      ),
      body: ListView.builder(
        itemCount: 100, // 提供足够的项目以使列表可滚动
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: 'Profile',
          ),
        ],
        currentIndex: _selectedIndex,
        onTap: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
      ),
    );
  }
}

上面的代码是一个标准的、没有任何特殊功能的Flutter应用。现在,让我们为其添加滚动检测逻辑。

2.2. 检测滚动:活用`NotificationListener`

虽然您可以将一个ScrollController直接附加到ListView上并添加监听器,但使用NotificationListener可以帮助保持组件树更清晰。您只需用NotificationListener<UserScrollNotification>组件包装ListView即可。UserScrollNotification特别有用,因为它仅在响应用户的直接滚动操作时才被分派,这使您能够将其与程序化滚动区分开,以实现更精确的控制。

首先,让我们添加一个状态变量_isVisible来控制BottomNavigationBar的可见性。


// 在_HomePageState类内部添加
bool _isVisible = true;

接下来,用NotificationListener包装ListView并实现onNotification回调。每当发生滚动事件时,都会调用此回调函数。


// 在build方法内部
// ...
body: NotificationListener<UserScrollNotification>(
  onNotification: (notification) {
    // 当用户向下滚动时(朝列表末尾方向)
    if (notification.direction == ScrollDirection.reverse) {
      if (_isVisible) {
        setState(() {
          _isVisible = false;
        });
      }
    }
    // 当用户向上滚动时(朝列表起始方向)
    else if (notification.direction == ScrollDirection.forward) {
      if (!_isVisible) {
        setState(() {
          _isVisible = true;
        });
      }
    }
    // 返回true以防止通知向上冒泡。
    return true; 
  },
  child: ListView.builder(
    itemCount: 100,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text('Item $index'),
      );
    },
  ),
),
// ...

现在,_isVisible状态会根据滚动方向而改变。但是,UI中还没有任何可见的变化。让我们使用这个状态变量来实际移动BottomNavigationBar。

2.3. 使用动画平滑移动

为了让BottomNavigationBar在_isVisible状态改变时平滑地出现和消失,我们需要动画。我们可以使用AnimationController搭配AnimatedContainerTransform.translate。在这里,我们将介绍使用AnimationControllerSizeTransition的方法,它更强大、更高效,并且效果更自然。

2.3.1. 初始化`AnimationController`

将一个AnimationController添加到_HomePageState并在initState中初始化它。由于这需要一个vsync,我们必须将TickerProviderStateMixin添加到_HomePageState类中。


// 修改类声明
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  // ... 现有变量

  late AnimationController _animationController;

  @override
  void initState() {
    super.initState();
    // 初始化动画控制器
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300), // 动画速度
      value: 1.0, // 初始值为1.0(完全可见)
    );
  }

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

  // ...
}

_animationController就像我们动画的“引擎”。我们设置它的duration并将其链接到一个vsync,以创建与屏幕刷新率同步的平滑动画。

2.3.2. 在滚动时触发动画

现在,我们不再在NotificationListener中调用setState,而是控制_animationController


// 修改onNotification回调
onNotification: (notification) {
  if (notification.direction == ScrollDirection.reverse) {
    // 向下滚动 -> 隐藏导航栏
    if (_animationController.isCompleted) { // 仅在导航栏完全可见时执行
        _animationController.reverse(); // 动画到0.0(隐藏状态)
    }
  } else if (notification.direction == ScrollDirection.forward) {
    // 向上滚动 -> 显示导航栏
    if (_animationController.isDismissed) { // 仅在导航栏完全隐藏时执行
        _animationController.forward(); // 动画到1.0(可见状态)
    }
  }
  return true;
},

在这里,_animationController.forward()驱动动画从头到尾(使导航栏可见),而reverse()则相反(使其隐藏)。我们添加了isCompletedisDismissed检查以防止不必要的调用。

2.3.3. 使用`SizeTransition`将动画应用于UI

最后,我们用SizeTransition组件包装我们的BottomNavigationBar,以将动画应用到UI上。


// 修改build方法中的bottomNavigationBar部分
// ...
bottomNavigationBar: SizeTransition(
  sizeFactor: _animationController,
  axisAlignment: -1.0,
  child: BottomNavigationBar(
    // ... 现有的BottomNavigationBar代码
  ),
),

SizeTransition会根据sizeFactor(从0.0到1.0)的值来改变其子组件的高度。当我们的动画控制器从1.0变为0.0时,导航栏的高度会平滑地变为0。axisAlignment: -1.0属性至关重要,它确保当高度缩小时,子组件会以其底部为基准进行对齐,从而产生向下滑出屏幕的视觉效果。

3. 完整代码与详细解析

结合我们讨论的所有概念,这里是完整的、可立即运行的代码。为了使逻辑更清晰,我们对状态管理方式进行了一些微调。


import 'package:flutter/material.dart';
import 'package:flutter/rendering.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: 'Scroll Aware Bottom Bar',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        scaffoldBackgroundColor: Colors.grey[200],
      ),
      debugShowCheckedModeBanner: false,
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
  int _selectedIndex = 0;

  // 底部导航栏的动画控制器
  late final AnimationController _hideBottomBarAnimationController;

  // 一个直接管理可见性的状态变量
  bool _isBottomBarVisible = true;

  @override
  void initState() {
    super.initState();
    _hideBottomBarAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
      // 初始值: 1.0 (完全可见)
      value: 1.0, 
    );
  }

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

  // 滚动通知处理函数
  bool _handleScrollNotification(ScrollNotification notification) {
    // 我们只关心用户驱动的滚动
    if (notification is UserScrollNotification) {
      final UserScrollNotification userScroll = notification;
      switch (userScroll.direction) {
        case ScrollDirection.forward:
          // 向上滚动: 显示导航栏
          if (!_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = true;
              _hideBottomBarAnimationController.forward();
            });
          }
          break;
        case ScrollDirection.reverse:
          // 向下滚动: 隐藏导航栏
          if (_isBottomBarVisible) {
            setState(() {
              _isBottomBarVisible = false;
              _hideBottomBarAnimationController.reverse();
            });
          }
          break;
        case ScrollDirection.idle:
          // 滚动停止: 什么也不做
          break;
      }
    }
    // 返回false以允许其他监听器接收通知
    return false;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Perfect Scroll-Aware Bar'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: _handleScrollNotification,
        child: ListView.builder(
          // 可以附加一个控制器以备将来使用(例如,处理边界情况)
          // controller: _scrollController, 
          itemCount: 100,
          itemBuilder: (context, index) {
            return Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: ListTile(
                leading: CircleAvatar(child: Text('$index')),
                title: Text('List Item $index'),
                subtitle: const Text('Scroll up and down to see the magic!'),
              ),
            );
          },
        ),
      ),
      // 使用SizeTransition来为高度添加动画
      bottomNavigationBar: SizeTransition(
        sizeFactor: _hideBottomBarAnimationController,
        // 当它缩小时,将其子组件对齐到底部
        axisAlignment: -1.0, 
        child: BottomNavigationBar(
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: 'Home',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.search),
              label: 'Search',
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              label: 'Profile',
            ),
          ],
          currentIndex: _selectedIndex,
          onTap: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          selectedItemColor: Colors.indigo,
          unselectedItemColor: Colors.grey,
        ),
      ),
    );
  }
}

在这个最终版本中,我们使用一个布尔值_isBottomBarVisible来明确管理可见性状态,并且只在状态改变时触发动画。这可以防止不必要的动画调用,使行为更加稳定。

4. 进阶课题:边界情况处理与高级技巧

基本功能现已完成。但是,在真实的生产环境中,可能会出现各种边界情况。让我们探讨一些高级技术来提高我们功能的健壮性。

4.1. 处理滚动到顶/底部的边界情况

如果用户非常快地“猛滑”滚动条并到达列表的顶部或底部,最后的滚动方向可能是reverse,导致导航栏保持隐藏状态。通常,当用户位于列表最顶部时,导航栏始终可见会带来更好的用户体验。

为了解决这个问题,我们可以将ScrollController与我们的NotificationListener结合使用。将一个控制器附加到ListView,并在通知回调或单独的监听器中检查滚动位置。


// 在_HomePageState中添加一个ScrollController
final ScrollController _scrollController = ScrollController();

// 在initState中,添加一个监听器
@override
void initState() {
  super.initState();
  // ... 现有代码
  _scrollController.addListener(_scrollListener);
}

void _scrollListener() {
    // 当滚动位置在顶部边缘时
    if (_scrollController.position.atEdge && _scrollController.position.pixels == 0) {
        if (!_isBottomBarVisible) {
            setState(() {
                _isBottomBarVisible = true;
                _hideBottomBarAnimationController.forward();
            });
        }
    }
}

// 将控制器附加到ListView
// ...
child: ListView.builder(
  controller: _scrollController,
// ...

上面的代码在ScrollController上使用了一个监听器来持续监控滚动位置。如果position.atEdge为true且position.pixels为0,则意味着我们已到达滚动视图的最顶部。此时,我们强制显示BottomNavigationBar。结合使用NotificationListenerScrollController.addListener可以实现更复杂的控制。

4.2. 与状态管理库(如Provider)集成

随着您的应用规模扩大,将UI与业务逻辑分离变得至关重要。使用像Provider或Riverpod这样的状态管理库有助于更清晰地组织您的代码。我们可以将BottomNavigationBar的可见性状态重构到一个ChangeNotifier中,以实现更好的关注点分离。

4.3. 与`CustomScrollView`和`Sliver`组件的兼容性

我们采用的NotificationListener方法最大的优点是它不依赖于任何特定的滚动组件。同样的代码在一个使用CustomScrollViewSliverAppBarSliverList和其他sliver的更复杂的屏幕上也能完美工作。


// body可以被替换为CustomScrollView,它仍然可以工作
body: NotificationListener<ScrollNotification>(
  onNotification: _handleScrollNotification,
  child: CustomScrollView(
    slivers: [
      const SliverAppBar(
        title: Text('Complex Scroll'),
        floating: true,
        pinned: false,
      ),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            // ...
          ),
          childCount: 100,
        ),
      ),
    ],
  ),
),

因为NotificationListener可以像捕获来自ListView的滚动通知一样,轻松地捕获来自CustomScrollView的通知,所以我们的隐藏/显示功能保持一致。这就是为什么NotificationListener方法比仅仅依赖ScrollController更灵活、更强大的原因。

总结:提升用户体验的点睛之笔

我们深入探讨了如何在Flutter中根据滚动方向动态地隐藏和显示BottomNavigationBar。我们不仅实现了基本功能,还涵盖了使用NotificationListener的灵活架构、利用AnimationControllerSizeTransition的平滑动画,甚至处理了到达滚动视图末端等边界情况。

这种动态UI不仅仅是一个“锦上添花”的功能;它是一个核心的UX元素,能让用户更深入地沉浸在应用的内容中,并最有效地利用有限的移动屏幕空间。我们鼓励您将今天学到的技术应用到自己的项目中,打造出感觉更专业、使用更愉悦的应用。

以下是关键要点总结:

  • 滚动检测:使用NotificationListener<UserScrollNotification>来捕捉用户的明确滚动意图。
  • 状态管理:通过一个简单的bool变量或更健壮的ChangeNotifier来管理导航栏的可见性状态。
  • 动画:根据状态控制一个AnimationController,并使用SizeTransitionSlideTransition来平滑地更新UI。
  • 边界情况处理:使用ScrollController作为辅助工具来处理特殊情况,如到达滚动边缘,从而完善实现。

现在,您应该能够自信地实现一个与Flutter中任何滚动视图完美集成的动态BottomNavigationBar了。我们建议您亲自运行代码,尝试不同的动画时长和曲线,找到最适合您应用的风格。

Wednesday, July 30, 2025

플러터와 유니티 연동, 두 세계의 장점만 취하는 방법

들어가며: 왜 플러터(Flutter)와 유니티(Unity)를 함께 사용해야 할까?

애플리케이션 개발의 세계는 끊임없이 진화하고 있습니다. 사용자들은 더 이상 단순히 기능만 갖춘 앱에 만족하지 않습니다. 아름답고 직관적인 UI(사용자 인터페이스)와 더불어, 몰입감 넘치는 인터랙티브 경험을 원합니다. 바로 이 지점에서 두 거인, 플러터와 유니티의 만남이 필연적으로 떠오릅니다.

플러터(Flutter)는 구글이 개발한 UI 툴킷으로, 단일 코드베이스로 iOS, Android, 웹, 데스크톱에서 네이티브 수준의 성능과 아름다운 UI를 구현하는 데 독보적인 강점을 가집니다. 빠르고 유연하며, 생산성이 매우 높죠. 하지만 복잡한 3D 그래픽, 물리 엔진, 고사양 게임과 같은 콘텐츠를 직접 구현하기에는 한계가 명확합니다.

반면, 유니티(Unity)는 세계 최고의 리얼타임 3D 개발 플랫폼입니다. 게임 개발은 물론, 건축 시각화, AR(증강현실), VR(가상현실), 디지털 트윈 등 몰입형 콘텐츠 제작에 있어서는 대체 불가능한 존재입니다. 하지만 유니티의 기본 UI 시스템(UGUI)은 일반적인 애플리케이션의 복잡하고 동적인 UI를 만드는 데 있어 플러터만큼 유연하거나 효율적이지 못합니다.

이 둘을 연동한다는 것은, 각자의 단점을 보완하고 장점만을 극대화하는 전략입니다. 즉, 앱의 전체적인 뼈대와 UI는 플러터로 빠르고 세련되게 구축하고, 3D 모델 뷰어, 미니 게임, AR 기능 등 고도의 그래픽 처리가 필요한 부분만 유니티로 제작하여 플러터 앱 안에 '위젯'처럼 삽입하는 것입니다. 이는 마치 잘 지어진 아파트(플러터 앱)에 최첨단 홈 시네마(유니티 뷰)를 설치하는 것과 같습니다.

핵심 원리와 적용 시나리오

어떻게 연동되는가?

플러터와 유니티 연동의 핵심은 '네이티브 통합'에 있습니다. 직접적으로 두 프레임워크가 소통하는 것이 아니라, 각 플랫폼(Android, iOS)의 네이티브 영역을 경유하여 다리(Bridge)를 놓는 방식입니다.

  1. 플러터 앱이 주가 됩니다. 사용자는 플러터로 만들어진 UI를 통해 앱과 상호작용합니다.
  2. 특정 화면이나 위젯이 필요한 시점에, 플러터는 네이티브 코드(Android의 경우 Java/Kotlin, iOS의 경우 Objective-C/Swift)를 호출하여 유니티 '뷰(View)'를 띄워달라고 요청합니다.
  3. 유니티 프로젝트는 일반적인 게임 앱이 아닌, 네이티브 라이브러리(Android의 경우 .AAR, iOS의 경우 Framework) 형태로 빌드됩니다.
  4. 네이티브 코드는 이 라이브러리를 로드하여 화면의 특정 영역에 유니티 씬(Scene)을 렌더링합니다. 이 렌더링된 화면이 플러터 위젯 트리 상에 표시됩니다.
  5. 데이터 통신은 이 네이티브 다리를 통해 양방향으로 이루어집니다. 예를 들어 플러터의 버튼을 누르면, `플러터 → 네이티브 → 유니티` 순서로 메시지가 전달되어 유니티 씬의 3D 모델 색상을 바꿀 수 있습니다. 반대로, 유니티 씬에서 특정 오브젝트를 터치하면 `유니티 → 네이티브 → 플러터` 순서로 이벤트가 전달되어 플러터의 텍스트 위젯 내용을 업데이트할 수 있습니다.

이 복잡한 과정을 쉽게 구현할 수 있도록 도와주는 것이 바로 flutter_unity_widget 같은 오픈소스 패키지입니다. 이 패키지는 위에서 설명한 네이티브 브릿지 코드를 추상화하여, 개발자가 플러터 코드 상에서 `UnityWidget`이라는 위젯을 사용하는 것만으로 간단히 유니티 뷰를 임베드하고 통신할 수 있게 해줍니다.

주요 적용 시나리오

  • 이커머스 앱의 3D 제품 뷰어: 가구, 자동차, 신발 등 제품을 360도 돌려보고, 색상을 바꿔보는 기능을 유니티로 구현하여 상품 상세 페이지에 삽입합니다.
  • 가구/인테리어 앱의 AR 배치 기능: 플러터로 만든 앱에서 'AR로 보기' 버튼을 누르면 유니티의 AR Foundation 기반 뷰가 활성화되어, 현실 공간에 가구를 배치해볼 수 있습니다.
  • 교육용 앱의 인터랙티브 콘텐츠: 인체 해부도, 행성 모델, 공룡 등을 3D로 보여주며 사용자가 직접 조작하고 학습할 수 있는 모듈을 유니티로 제작합니다.
  • 기업용 앱의 설비/건물 디지털 트윈: 공장 설비나 건물의 데이터를 3D 모델과 연동하여 시각화하고, 특정 부품을 클릭하면 플러터 UI에 상세 정보가 표시되도록 합니다.
  • 일반 앱 속의 미니 게임: 앱의 주요 기능과는 별개로, 사용자 참여를 유도하기 위한 간단한 3D 미니 게임을 유니티로 만들어 이벤트 페이지 등에 포함시킬 수 있습니다.

실전 연동 과정 (flutter_unity_widget 기준)

이론은 충분히 알았으니, 이제 실제 구현 과정을 간략하게 살펴보겠습니다. 상세한 설정은 패키지 버전에 따라 달라질 수 있으므로 공식 문서를 항상 참조하는 것이 좋습니다.

1. 플러터 프로젝트 설정

먼저, 플러터 프로젝트의 `pubspec.yaml` 파일에 `flutter_unity_widget` 의존성을 추가합니다.


dependencies:
  flutter:
    sdk: flutter
  flutter_unity_widget: ^2022.2.0

그 후 `flutter pub get` 명령어로 패키지를 설치합니다.

2. 유니티 프로젝트 설정 및 빌드

  1. 유니티 허브에서 새 3D 프로젝트를 생성합니다.
  2. `flutter_unity_widget` 패키지의 Unity 소스를 다운로드받아 유니티 프로젝트의 `Assets` 폴더에 `unity-v2` 또는 유사한 이름의 폴더를 생성하고 그 안에 넣습니다. 이 폴더에는 플러터와의 통신을 위한 스크립트와 빌드 설정이 포함되어 있습니다.
  3. `Tools/Flutter/Export (Android)` 또는 `Export (iOS)` 메뉴를 사용하여 프로젝트를 네이티브 라이브러리 형태로 빌드합니다.
    • Android: 빌드가 완료되면 플러터 프로젝트의 `android/unityLibrary` 와 같은 경로에 .AAR 파일과 관련 리소스가 생성됩니다.
    • iOS: 빌드가 완료되면 `ios/UnityLibrary` 와 같은 경로에 Xcode 프로젝트가 생성됩니다.

이 과정은 패키지가 제공하는 자동화 스크립트에 의해 대부분 처리됩니다.

3. 플러터 위젯에 유니티 뷰 추가하기

이제 플러터 코드에서 유니티 뷰를 위젯으로 사용할 수 있습니다. `UnityWidget`을 화면에 배치하고, 컨트롤러를 통해 상호작용합니다.


import 'package:flutter/material.dart';
import 'package:flutter_unity_widget/flutter_unity_widget.dart';

class UnityDemoScreen extends StatefulWidget {
  @override
  _UnityDemoScreenState createState() => _UnityDemoScreenState();
}

class _UnityDemoScreenState extends State<UnityDemoScreen> {
  static final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
  UnityWidgetController? _unityWidgetController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(title: Text('Flutter & Unity Demo')),
      body: Card(
        margin: const EdgeInsets.all(8),
        clipBehavior: Clip.antiAlias,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(20.0),
        ),
        child: Stack(
          children: <Widget>[
            UnityWidget(
              onUnityCreated: onUnityCreated,
              onUnityMessage: onUnityMessage,
              onUnitySceneLoaded: onUnitySceneLoaded,
            ),
            Positioned(
              bottom: 20,
              right: 20,
              child: ElevatedButton(
                onPressed: () {
                  // 플러터에서 유니티로 메시지 전송
                  changeCubeColor();
                },
                child: Text('Change Color'),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // 유니티 씬 로드가 완료되면 호출
  void onUnityCreated(controller) {
    this._unityWidgetController = controller;
  }
  
  // 유니티로부터 메시지를 수신하면 호출
  void onUnityMessage(message) {
    print('Received message from Unity: ${message.toString()}');
    // 예를 들어, 유니티에서 보낸 점수를 플러터 UI에 표시
  }

  // 유니티 씬 로드 상태 변경 시 호출
  void onUnitySceneLoaded(SceneLoaded? scene) {
    if (scene != null) {
      print('Received scene loaded from Unity: ${scene.name}');
    }
  }

  // 유니티로 메시지를 보내는 함수 예시
  void changeCubeColor() {
    _unityWidgetController?.postMessage(
      'Cube', // 유니티 내 GameObject 이름
      'ChangeColor', // 호출할 C# 스크립트의 메서드 이름
      '#FF0000', // 전달할 파라미터
    );
  }
}

4. 유니티에서 플러터와 통신하기

유니티에서는 플러터로부터 메시지를 수신하고, 플러터로 메시지를 보낼 수 있는 C# 스크립트를 작성해야 합니다. `flutter_unity_widget`에서 제공하는 `UnityMessageManager`를 사용합니다.


using UnityEngine;
// flutter_unity_widget에서 제공하는 통신 스크립트 사용
using FlutterUnityIntegration; 

public class CubeController : MonoBehaviour
{
    // 이 메서드는 플러터의 postMessage를 통해 호출됩니다.
    public void ChangeColor(string colorCode)
    {
        // 색상 코드를 Color 객체로 변환
        Color newColor;
        if (ColorUtility.TryParseHtmlString(colorCode, out newColor))
        {
            GetComponent<Renderer>().material.color = newColor;
        }

        // 작업 완료 후 플러터로 메시지 전송
        SendStateToFlutter();
    }

    // 마우스 클릭 시 플러터로 이벤트 전송
    private void OnMouseDown()
    {
        UnityMessageManager.Instance.SendMessageToFlutter("CubeClicked");
    }
    
    // 현재 큐브 색상 정보를 플러터로 전송하는 예시
    private void SendStateToFlutter() {
        string currentColor = "#" + ColorUtility.ToHtmlStringRGB(GetComponent<Renderer>().material.color);
        UnityMessageManager.Instance.SendMessageToFlutter("Cube color is now " + currentColor);
    }
}

위 예시처럼, 플러터와 유니티는 GameObject 이름과 메서드 이름을 키(key)로 삼아 문자열 데이터를 주고받으며 긴밀하게 상호작용할 수 있습니다.

반드시 고려해야 할 사항들

플러터와 유니티 연동은 강력한 만큼, 신중하게 접근해야 할 몇 가지 과제가 있습니다.

  • 앱 용량 증가: 유니티 엔진과 3D 에셋들이 포함되므로 순수 플러터 앱에 비해 최종 빌드된 앱의 크기가 상당히 커집니다. 모바일 환경에서는 민감한 문제일 수 있습니다.
  • 성능 및 메모리 관리: 두 개의 고성능 프레임워크가 동시에 실행되는 것이므로, 특히 저사양 기기에서는 메모리 사용량과 배터리 소모가 많아질 수 있습니다. 유니티 씬의 최적화가 필수적이며, 유니티 뷰가 화면에 보이지 않을 때는 일시정지(pause)시키는 등 생명주기(Lifecycle) 관리가 중요합니다.
  • 빌드 복잡성: 플러터와 유니티라는 두 개의 다른 생태계의 빌드 파이프라인을 모두 관리해야 합니다. 버전 호환성 문제나 빌드 설정 오류가 발생할 가능성이 더 높습니다.
  • 디버깅의 어려움: 문제가 발생했을 때, 이것이 플러터의 문제인지, 유니티의 문제인지, 아니면 둘 사이의 통신(브릿지) 문제인지 파악하기가 더 까다로울 수 있습니다.

결론: 현명한 선택과 집중

플러터와 유니티를 함께 사용하는 것은 '모든 문제를 해결하는 만능 열쇠'가 아닙니다. 이는 분명 '고급 기술'에 속하며, 프로젝트의 요구사항이 이 기술을 사용했을 때 얻는 이점이 앞서 언급한 단점들(용량, 성능, 복잡성)을 감수할 만큼 충분히 클 때 선택해야 하는 전략적 카드입니다.

단순히 3D 모델 하나를 보여주는 것이 목적이라면, 플러터에서 직접 3D 렌더링을 지원하는 `model_viewer_plus`와 같은 가벼운 패키지를 사용하는 것이 더 현명할 수 있습니다.

하지만 사용자와의 실시간 상호작용이 필요한 복잡한 3D 환경, AR 기능, 물리 시뮬레이션 등이 앱의 핵심적인 경험이라면, 플러터와 유니티의 조합은 다른 어떤 기술로도 대체하기 어려운 강력한 시너지를 발휘할 것입니다. 이 조합을 통해 개발자는 빠르고 아름다운 UI와 몰입감 넘치는 3D 경험이라는 두 마리 토끼를 모두 잡고, 사용자에게 전에 없던 새로운 가치를 제공하는 애플리케이션을 만들어낼 수 있습니다.

프로젝트의 본질을 꿰뚫고, 기술의 장단점을 명확히 이해하여 가장 적합한 도구를 선택하는 것, 그것이 바로 뛰어난 개발자의 역량일 것입니다. 플러터와 유니티 연동은 그 선택지 중 하나로서 당신의 개발 무기고를 더욱 풍성하게 만들어 줄 것입니다.

Flutter and Unity: Bridging 2D UI and 3D Worlds

Why Combine Flutter and Unity in the First Place?

In today's competitive app landscape, a functional user interface is no longer enough. Users expect and demand applications that are not only intuitive and beautiful but also engaging and immersive. This is where the powerful combination of two industry-leading platforms, Flutter and Unity, comes into play.

Flutter, Google's UI toolkit, excels at building high-performance, natively compiled applications for mobile, web, and desktop from a single codebase. Its strength lies in its speed, expressive UI capabilities, and developer productivity. However, when it comes to rendering complex 3D graphics, running sophisticated physics engines, or building high-fidelity games, Flutter has its limitations.

Unity, on the other hand, is the world's premier real-time 3D development platform. It is the undisputed king of immersive content creation, from blockbuster games to architectural visualization, augmented reality (AR), virtual reality (VR), and digital twins. Yet, Unity's built-in UI system (UGUI) can feel cumbersome and less efficient for creating the kind of complex, data-driven, and highly polished user interfaces common in modern non-gaming apps.

Integrating these two frameworks is a strategic decision to leverage the best of both worlds. The core idea is to let each platform do what it does best: use Flutter for the main application structure, navigation, and user interface, while embedding a Unity-powered view as a "widget" for specific, graphically intensive features like 3D model viewers, AR experiences, or mini-games. It's like building a sleek, modern condominium (the Flutter app) and installing a state-of-the-art IMAX theater (the Unity view) inside one of its rooms.

The Architectural Blueprint and Common Use Cases

How Does the Integration Actually Work?

The magic behind Flutter-Unity integration lies in a 'Host-Guest' architecture facilitated by the native platform layer (Android or iOS). The two frameworks don't talk to each other directly; they communicate through a native bridge.

  1. The Flutter App as the Host: The user primarily interacts with the app built with Flutter. It controls the overall app state, navigation, and UI.
  2. Unity as the Guest View: The Unity project is not built as a standalone application. Instead, it's exported as a native library—an Android Archive (.AAR) for Android or a Framework for iOS.
  3. The Native Bridge: When the user navigates to a screen that requires the 3D content, the Flutter app uses a platform channel to send a message to the native code (Kotlin/Java on Android, Swift/Objective-C on iOS). This native code is responsible for loading the Unity library and displaying its rendered output within a native `View` (Android) or `UIView` (iOS).
  4. Embedding into Flutter: This native view is then presented to Flutter as a widget using a mechanism called Platform Views. This allows the Unity-rendered content to be placed and managed within Flutter's widget tree, just like any other widget.
  5. Two-Way Communication: Data flows back and forth over this native bridge. A button press in Flutter can send a message (`Flutter -> Native -> Unity`) to change a property of a 3D object in the Unity scene. Conversely, an interaction within the Unity scene (e.g., tapping on a 3D model) can send an event back (`Unity -> Native -> Flutter`) to update a text widget in the Flutter UI.

Fortunately, you don't have to build this complex bridging mechanism from scratch. The popular open-source package flutter_unity_widget abstracts away most of this complexity, providing a simple `UnityWidget` that developers can use directly in their Flutter code.

Powerful Use Cases for This Hybrid Approach

  • E-commerce 3D Product Viewers: Allow customers to view products like furniture, sneakers, or electronics in 3D, rotating them and changing colors or configurations in real-time.
  • Augmented Reality (AR) Previews: In an interior design app, a user could browse 2D furniture listings and then tap an "View in my room" button, which launches a Unity AR view to place a virtual sofa in their actual living room.
  • Interactive Educational Content: Create engaging learning modules, such as a 3D human anatomy explorer, a solar system simulator, or an interactive historical artifact viewer.
  • Industrial Digital Twins: Develop enterprise apps for visualizing factory machinery or building infrastructure. Tapping a specific component in the 3D Unity view could bring up a Flutter UI panel with its maintenance history and real-time sensor data.
  • In-App Mini-Games: Increase user engagement and retention by embedding small, fun 3D games or interactive experiences built with Unity inside a larger, utility-focused Flutter application.

A Practical Walkthrough (Using flutter_unity_widget)

Let's briefly outline the steps involved. Note that specific configurations can change with package versions, so always consult the official documentation.

1. Flutter Project Setup

Add the `flutter_unity_widget` dependency to your `pubspec.yaml` file:


dependencies:
  flutter:
    sdk: flutter
  flutter_unity_widget: ^2022.2.0 # Use the latest compatible version

Then, run `flutter pub get` in your terminal to install the package.

2. Unity Project Configuration and Export

  1. Create a new 3D project in the Unity Hub.
  2. Download the Unity integration files provided by the `flutter_unity_widget` package. You'll typically place these into a specific folder within your Unity project's `Assets` directory (e.g., `Assets/FlutterUnityPlugin`). These files contain essential scripts for communication and build automation.
  3. Use the provided menu option (e.g., `Tools -> Flutter -> Export`) to build the Unity project as a library for your target platform.
    • For Android: This process will generate a library module, which you then place inside your Flutter project's `android` directory.
    • For iOS: This will export a `UnityLibrary` Xcode project, which needs to be integrated into your Flutter project's iOS workspace.

The package often includes scripts to help automate this export and integration process.

3. Embedding the Unity View in a Flutter Widget

Now, you can use the `UnityWidget` in your Flutter UI. A controller is used to manage its state and communication.


import 'package:flutter/material.dart';
import 'package:flutter_unity_widget/flutter_unity_widget.dart';

class UnityViewPage extends StatefulWidget {
  @override
  _UnityViewPageState createState() => _UnityViewPageState();
}

class _UnityViewPageState extends State<UnityViewPage> {
  UnityWidgetController? _unityWidgetController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Unity Inside Flutter")),
      body: Stack(
        children: [
          UnityWidget(
            onUnityCreated: _onUnityCreated,
            onUnityMessage: _onUnityMessage,
          ),
          Positioned(
            bottom: 30,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  child: Text("Rotate Left"),
                  onPressed: () => _sendMessageToUnity("Rotate", "-1"),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  child: Text("Rotate Right"),
                  onPressed: () => _sendMessageToUnity("Rotate", "1"),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  // Called when the Unity widget is created and ready for communication.
  void _onUnityCreated(UnityWidgetController controller) {
    setState(() {
      _unityWidgetController = controller;
    });
  }

  // Handles messages sent FROM Unity TO Flutter.
  void _onUnityMessage(String message) {
    debugPrint('Message from Unity: $message');
    // Display a snackbar or update a Flutter widget with the received data.
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Unity says: $message')),
    );
  }

  // Helper function to send messages FROM Flutter TO Unity.
  void _sendMessageToUnity(String methodName, String message) {
    _unityWidgetController?.postMessage(
      'Player',     // The name of the GameObject in Unity.
      methodName,   // The name of the method in the C# script.
      message,      // The data to pass.
    );
  }

  @override
  void dispose() {
    _unityWidgetController?.dispose();
    super.dispose();
  }
}

4. Communicating from Unity to Flutter

In your Unity project, you'll need a C# script attached to a GameObject to handle incoming messages and send outgoing ones.


using UnityEngine;
// Import the necessary class from the plugin.
using FlutterUnityIntegration;

public class PlayerController : MonoBehaviour
{
    private float rotationSpeed = 30.0f;

    // This public method is called from Flutter's `postMessage`.
    public void Rotate(string direction)
    {
        float dir = float.Parse(direction);
        transform.Rotate(Vector3.up, rotationSpeed * dir * Time.deltaTime);
    }

    void Update()
    {
        // Example of sending a message to Flutter on a key press (for editor testing)
        // or a touch event.
        if (Input.GetMouseButtonDown(0))
        {
            // Call this to send a message to Flutter.
            UnityMessageManager.Instance.SendMessageToFlutter("Model Tapped!");
        }
    }
}

Critical Considerations and Trade-offs

While powerful, this integration is not a silver bullet. You must weigh the pros and cons carefully.

  • Increased App Size: Integrating the Unity runtime and 3D assets will significantly increase your app's final binary size compared to a pure Flutter app. This is a critical factor for mobile distribution.
  • Performance and Resource Management: You are essentially running two resource-intensive frameworks simultaneously. This can lead to higher CPU/GPU usage, increased memory consumption, and faster battery drain, especially on lower-end devices. Careful optimization of your Unity scene is non-negotiable. Proper lifecycle management (e.g., pausing Unity when it's not visible) is crucial.
  • Build Complexity: You now have to manage two separate build pipelines. This introduces potential version compatibility issues between Flutter, Unity, Xcode, Android Studio, and the integration plugin itself.
  • Debugging Challenges: When something goes wrong, it can be difficult to determine the source of the bug. Is it a Flutter layout issue, a Unity rendering glitch, or a problem with the communication bridge between them?

Conclusion: A Strategic Choice for High-Impact Features

Integrating Flutter and Unity is an advanced technique. It should be chosen when the value of the immersive 3D or AR features it enables clearly outweighs the inherent costs of increased complexity, app size, and performance overhead.

If your app only needs to display a simple, non-interactive 3D model, a lighter-weight solution like a dedicated Flutter 3D rendering package (e.g., `model_viewer_plus`) might be a more pragmatic choice.

However, when your app's core value proposition revolves around complex, interactive 3D environments, real-time physics, or cutting-edge AR experiences, the Flutter-Unity synergy is unparalleled. It allows you to deliver a product with a slick, modern, and performant UI, combined with the kind of immersive experience that captivates users and sets your application apart from the competition. By understanding the architecture and its trade-offs, developers can unlock a new frontier of app development, merging the worlds of utility and immersion into a single, cohesive user experience.