통조림

[Flutter] Indexed Stack - 안드로이드 뒤로가기 본문

Software/Flutter

[Flutter] Indexed Stack - 안드로이드 뒤로가기

고랭지참치 2024. 4. 2. 20:51

나는 아이폰만 사용한지 10년이 돼서 안드로이드 폰에서 제공하는 뒤로가기 기능에 대해서 망각할 때가 자주 있다.

실제로 페이스북 앱에서 안드로이드 폰에 제공하던 뒤로가기 버튼 기능을 제거하여 이런저런 밈들도 만들어진듯 하다.

 

하지만 유튜브 같은 대형앱들에서는 대부분 상황에 맞는 뒤로가기 기능을 제공하고 있으며, 특히 BottomNavigation으로 네비게이팅을 처리하는 서비스의 경우 BottomNav 아이템을 클릭했던 순서의 역순으로 뒤로가기 버튼을 누르면 돌아가도록 해준다.

 

Flutter에서도 해당 기능을 쉽게 구현할 수 있는데, 그 방법 중 하나인 IndexedStack 위젯에 대해기록하려고 한다.

전체 코드 깃허브

https://github.com/KoreanTuna/Indexed-Stack-Study

 

IndexedStack 위젯의 제공 파라미터

class IndexedStack extends StatelessWidget {
  /// Creates a [Stack] widget that paints a single child.
  const IndexedStack({
    super.key,
    this.alignment = AlignmentDirectional.topStart,
    this.textDirection,
    this.clipBehavior = Clip.hardEdge,
    this.sizing = StackFit.loose,
    this.index = 0,
    this.children = const <Widget>[],
  });
  
    @override
  Widget build(BuildContext context) {
    final List<Widget> wrappedChildren = List<Widget>.generate(children.length, (int i) {
      return Visibility.maintain(
        visible: i == index,
        child: children[i],
      );
    });
    return _RawIndexedStack(
      alignment: alignment,
      textDirection: textDirection,
      clipBehavior: clipBehavior,
      sizing: sizing,
      index: index,
      children: wrappedChildren,
    );
  }
}

IndexedStack은 Stack 위젯 위에 Visibility위젯을 여러개 쌓아올린 형식으로 구현된다.
현재 index와 동일한 List 아이템의 visible을 true로 한다.

즉, IndexedStack children에 들어가는 위젯들은 함께 빌드되며, 보이지 않을 때는 Visibility 위젯의 로직에 따라 위젯 서브트리에서 0의 사이즈를 가진 sizedBox가 대신 트리 위치를 차지하고 있게 된다.

"... By default, the visible property controls whether the child is included in the subtree or not; when it is not visible, the replacement child (typically a zero-sized box) is included instead. ... "
https://api.flutter.dev/flutter/widgets/Visibility-class.html

 

IndexedStack 실제로 사용해보기

indexedStack 구현방법은 간단하다. children에 index에 따라 노출될 Widget List를 제공하고, index에 원하는 index를 제공한다. 기본값은 0이다.

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: IndexedStack(
            index: 0,
            children: [
              Text('1번 인덱스 위젯'),
              Text('2번 인덱스 위젯'),
              Text('3번 인덱스 위젯'),
            ],
          ),
        ),
      ),
    );
  }
}

빌드화면



flutter hooks로 index state관리해주기

 

useState 사용해서 index State변수 만들어주기

final ValueNotifier<int> index = useState(0);


### IndexedStack 위젯 index에 state값 전달 ``` IndexedStack( index: index.value, children: const [ DemoScreen(title: 'Screen 1'), DemoScreen(title: 'Screen 2'), DemoScreen(title: 'Screen 3'), ], ), ```



Bottom Nav위젯 만들기

Icon을 터치하면 index state값 업데이트 해주기

Row(
  mainAxisAlignment: MainAxisAlignment.spaceAround,
  children: [
    _BottomNavItem(
      onPressed: () {
        index.value = 0;
      },
      icon: const Icon(Icons.home),
    ),
    _BottomNavItem(
      onPressed: () {
        index.value = 1;
      },
      icon: const Icon(Icons.search),
    ),
    _BottomNavItem(
      onPressed: () {
        index.value = 2;
      },
      icon: const Icon(Icons.settings),
    ),
  ],
),



전체코드

class HomeScreen extends HookConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ValueNotifier<int> index = useState(0);

    return MaterialApp(
      home: Scaffold(
        body: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Expanded(
              child: IndexedStack(
                index: index.value,
                children: const [
                  DemoScreen(title: 'Screen 1'),
                  DemoScreen(title: 'Screen 2'),
                  DemoScreen(title: 'Screen 3'),
                ],
              ),
            ),
            Container(
              width: MediaQuery.of(context).size.width,
              height: 56,
              decoration: BoxDecoration(
                color: Colors.grey[100],
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: [
                  _BottomNavItem(
                    onPressed: () {
                      index.value = 0;
                    },
                    icon: const Icon(Icons.home),
                  ),
                  _BottomNavItem(
                    onPressed: () {
                      index.value = 1;
                    },
                    icon: const Icon(Icons.search),
                  ),
                  _BottomNavItem(
                    onPressed: () {
                      index.value = 2;
                    },
                    icon: const Icon(Icons.settings),
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}

작동 화면

 



뒤로가기 기능 적용

이 리스트에는 사용자가 아이콘을 선택할 때마다 리스트에 해당 index가 추가될 것이다.

final ValueNotifier<List<int>> navStack = useState([0]);

 

아이콘이 터치될때마다 수행할 로직

현재 선택된 index와 다른 index의 아이템이 터치됐을때, navStack list에 현재 선택된 index를 추가하고, index State를 업데이트 한다.

List.generate(3, (itemIndex) {
  return BottomNavItem(
    onPressed: () {
      if (itemIndex != index.value) {
        navStack.value.add(index.value);
        index.value = itemIndex;
      }
    },
    icon: icons[itemIndex],
  );
}),

 

뒤로가기가 눌렸을 때, 수행될 로직 추가

useState로 선언된 index State를 함수안에서 활용하기 쉽고 반복되는 함수의 메모리 효율을 위해, build안에 useCallBack으로 onPop함수를 선언한다.

현재 navStack List가 비어있지 않다면, 마지막 아이템으로 index State를 업데이트 해준 후 List의 마지막 값을 제거한다.

    final void Function() onPop = useCallback(
      () {
        if (navStack.value.isNotEmpty) {
          index.value = navStack.value.last;
          navStack.value.removeLast();
        }
      },
    );

 

PopScope 위젯 생성 후 로직 추가

위에서 생성해준 onPop함수를 PopScope onPopInvoked에 전달하고,
canPop은 navStack에 오직 하나의 인덱스만 남겨졌을때 실행될 수 있도록 한다.

PopScope(
  canPop: navStack.value.length == 1,
  onPopInvoked: (bool value) {
    onPop();
  },
  child: Scaffold(
  ...

 

전체 코드

class HomeScreen extends HookConsumerWidget {
  const HomeScreen({super.key});

  static const List<Icon> icons = [
    Icon(Icons.home),
    Icon(Icons.search),
    Icon(Icons.settings),
  ];
  
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ValueNotifier<int> index = useState(0);
    final ValueNotifier<List<int>> navStack = useState([0]);

    final void Function() onPop = useCallback(
      () {
        if (navStack.value.isNotEmpty) {
          index.value = navStack.value.last;
          navStack.value.removeLast();
        }
      },
    );

    return MaterialApp(
      home: PopScope(
        canPop: navStack.value.length == 1,
        onPopInvoked: (bool value) {
          onPop();
        },
        child: Scaffold(
          body: Column(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Expanded(
                child: IndexedStack(
                  index: index.value,
                  children: const [
                    DemoScreen(title: 'Screen 1'),
                    DemoScreen(title: 'Screen 2'),
                    DemoScreen(title: 'Screen 3'),
                  ],
                ),
              ),
              Container(
                width: MediaQuery.of(context).size.width,
                height: 56,
                decoration: BoxDecoration(
                  color: Colors.grey[100],
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: List.generate(3, (itemIndex) {
                    return BottomNavItem(
                      onPressed: () {
                        if (itemIndex != index.value) {
                          navStack.value.add(index.value);
                          index.value = itemIndex;
                        }
                      },
                      icon: icons[itemIndex],
                    );
                  }),
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

실행 화면



결론

IndexedStack의 뒤로가기를 구현하는 것은 어렵지 않다.
index값을 프로젝트 전역에서 사용해야한다면, useState가 아닌 riverpod Provider로 관리해줘도 될 것 같다.