Foggy day

[Flutter] showModalBottomSheet(바텀시트) - 사용법 본문

Flutter/Flutter widget

[Flutter] showModalBottomSheet(바텀시트) - 사용법

jinhan38 2023. 4. 2. 00:22

 

 

이번 포스팅에서는 showModalBottomSheet에 대해 알아보겠습니다. showModalBottomSheet는 팝업과 유사하지만 아래에서 위로 올라오는 형태의 팝업입니다. 

 

 

 

1. 바텀시트 호출

2. 바텀시트의 특성들

3. 바텀시트와 Future

4. 바텀시트의 AnimationController

 

최종 동영상

 

 

 

 

 


1. 바텀시트 호출

먼저 바텀시트를 호출하는 방법부터 알아보겠습니다.

바텀시트를 호출하기 위해서는 showModalBottomSheet 함수를 사용해야 합니다. 기본적으로 context와 WidgetBuilder를 전달해야 합니다.  

  showModalBottomSheet(
    context: context,
    builder: (context) {
      return Container(
        color: Colors.red,
        height: 200,
      );
    },
  );

 

 

 

 

 

 

 

 

 

 

 

 


2. 바텀시트의 특성들

바텀시트에는 여러 특성들이 있습니다. 이 중에서 바텀시트의 사이즈와 관련된 것들만 상세히 설명하고, 그 외의 특성들은 주석으로 대체하겠습니다.

바텀시트의 사이즈와 관련된 특성들은 constraints, isScrollControlled, useSafeArea가 있습니다. 

  • constraints : BoxConstraints 클래스를 전달해야 하고, min/max width와 min/max height를 입력할 수 있습니다. 
  • isScrollControlled : 공식 문서에는 
    "Specifies whether this is a route for a bottom sheet that will utilize DraggableScrollableSheet." 이렇게 설명하고 있습니다. 하지만 이해가 잘 가지 않아서 경험적으로 어떤 차이가 있는지를 알아봤습니다. isScrollControlled를 false로 설정하면 바텀시트의 최대 높이는 화면의 절반입니다. true로 설정하면 화면 전체를 사용할 수 있습니다. 
  • useSafeArea : SafeArea를 사용하지 말지에 대한 값입니다. true로 설정하면 상태바 영역을 사용할 수 없고, false로 설정하면 상태바의 영역을 사용할 수 있습니다. 그래서 isScrollControlled를 true로 설정하고 useSafeArea를 false로 설정하면 바텀시트가 상태바의 영역까지 올라갈 수 있습니다. 

 

 

 

showModalBottomSheet(
  context: context,
  builder: (context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(30),
      child: ListView(
        children: [
          ...List.generate(
            10,
            (index) => Container(
              height: 100,
              width: double.infinity,
              margin: const EdgeInsets.all(20),
              color: Colors.white,
              alignment: Alignment.center,
              child: Text(
                "index : $index",
                style: const TextStyle(fontSize: 20),
              ),
            ),
          )
        ],
      ),
    );
  },
  elevation: 50,

  /// 바텀시트 드래그 가능 여부
  enableDrag: true,

  /// 바텀시트가 아닌 부분을 클릭했을 때
  /// 바텀시트를 닫을지 말지 설정
  isDismissible: true,

  /// 바텀시트 아닌 영역의 컬러
  barrierColor: Colors.grey.withOpacity(0.3),

  /// 바텀시트 배경 컬러
  backgroundColor: Colors.blue.shade200,

  /// 사이즈 조절
  constraints: const BoxConstraints(
    minWidth: 100,
    maxWidth: 300,
    minHeight: 100,
    maxHeight: 500,
  ),

  /// 바텀시트의 모양 설정
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(30),
    side: const BorderSide(color: Colors.red, width: 5),
  ),

  /// false = 화면의 절반만 차지함
  /// true = 전체 화면 차이
  isScrollControlled: true,

  /// SafeArea 사용할지 말지 설정
  /// isScrollControlled을 true로 설정하면 상태바까지 올라감
  /// 이때 useSafeArea를 true로 설정하면 상태바는 사용 불가
  useSafeArea: true,
);

 

다른 타입의 shape들을 알아보고 싶다면 버튼을 설명하는 포스팅을 참고해 주시기 바랍니다. 버튼 포스팅의 마지막 부분에서 다양한 Border들을 다루고 있습니다. 

https://jinhan38.tistory.com/139

 

[Flutter] Button Widget - 예제(ElevatedButton, TextButton, OutlinedButton, IconButton)

이번 포스팅에서는 Button 위젯의 예제를 만들어봤습니다. Flutter에서 클릭에 대한 액션은 GestureDetector, InkWell, Button을 사용해 구현합니다. Button 위젯들에는 기본적으로 설정된 UI가 있어서 간단히

jinhan38.com

 

 

 

 


3. 바텀시트와 Future

바텀시트를 닫은 후의 콜백과 바텀시트가 올라온 다음에 타이머를 콜백을 설정할 수도 있습니다.

  • timeout : Duration과 onTimeout 콜백을 이용해서 입력한 시간이 지나면 콜백을 받을 수 있게 설정할 수 있습니다.
  • then : Navigator.pop이나 유저의 터치, animation 등을 이용해서 바텀시트가 내려갔을 때 호출됩니다.
  • whenComplete : then 함수가 호출된 후 호출됩니다. 
showModalBottomSheet(
      
      ... 생략 ... 
      
        )
    /// 7초 후  호출
    .timeout(const Duration(seconds: 7), onTimeout: () {})

    /// then -> 바텀시트 닫은 경우 호출됨
    .then((value) {})

    /// whenComplete -> then 다음에 호출됨
    .whenComplete(() {});

 

 

 

 

 


4. 바텀시트의 AnimationController

바텀시트에는 AnimationController 타입의 transitionAnimationController 속성이 있습니다. 바텀시트의 AnimationController를 사용하면 바텀시트의 상태, 애니메이션 시간, 바텀시트 높이 조절 등의 기능을 구현할 수 있습니다.

 

AnimationController 생성

  • addListener : addListener 리스너를 붙이면 바텀시트가 움직일 때 마다 콜백이 호출됩니다.
  • animationController.value : 바텀시트의 애니메이션 진척률을 0~1 사이로 표준화해서 알려줍니다.
  • animationController.sataus : 바텀시트의 애니메이션이 어떤 상태인지 알려줍니다. enum 클래스 AnimationStatus에는 forward, completed , reverse, dismissed 4가지 타입이 있습니다. forward는 바텀시트가 아래에서 위로 올라오고 있는 상태이고 completed는 올라오는 애니메이션이 완료된 상태입니다. reverse는 바텀시트가 위에서 아래로 내려오고 있는 상태이고, dismissed는 바텀시트가 완전히 닫힌 상태입니다. 
  • duration : 바텀시트가 올라오는 속도입니다.
  • reverseDuration : 바텀시트가 내려가는 속도입니다. 
import 'package:flutter/material.dart';

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

  @override
  State<BottomSheetScreen> createState() => _BottomSheetScreenState();
}

class _BottomSheetScreenState extends State<BottomSheetScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController animationController;

  @override
  void initState() {
    animationController = BottomSheet.createAnimationController(this);
    animationController
      ..addListener(() {
        print(
            'listen : ${animationController.value}, : ${animationController.status}');
      })
      ..duration = const Duration(milliseconds: 500)
      ..reverseDuration = const Duration(milliseconds: 500);
    super.initState();
  }

  @override
  void dispose() {
    animationController.dispose();
    super.dispose();
  }
  
  
	
    ... 생략 ...
    
    
}

 

바텀시트 높이 조절

바텀시트의 높이 조절은 animateTo와 animateBack 함수에 0~1 사이 값을 입력함으로써 할 수 있습니다. 

0 = 바텀시트 완전히 내려감

1 = 바텀시트 최대까지 올라옴 

  • animateTo : 입력한 값으로 이동합니다. 이동하는 동안 AnimationStatus는 forward입니다. 0이나 1이 되면 AnimationStatus는 completed입니다.
  • animateBack : 입력한 값으로 이동합니다. 이동하는 동안 AnimationStatus는 reverse입니다. 0이나 1이 되면 AnimationStatus는 dismissed입니다. 
animationController.animateTo(0.7)

animationController.animateTo(0.3)

animationController.animateBack(0.6)

animationController.animateBack(0.2)

 

 

 

 

 

 


Full code

import 'package:flutter/material.dart';

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

  @override
  State<BottomSheetScreen> createState() => _BottomSheetScreenState();
}

class _BottomSheetScreenState extends State<BottomSheetScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController animationController;

  @override
  void initState() {
    animationController = BottomSheet.createAnimationController(this);
    animationController
      ..addListener(() {
        print(
            'listen : ${animationController.value}, : ${animationController.status}');
      })
      ..duration = const Duration(milliseconds: 500)
      ..reverseDuration = const Duration(milliseconds: 500);
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("BottomSheetScreen"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
                onPressed: () {
                  _showModalBottomSheet();
                },
                child: const Text("showModalBottomSheet")),
          ],
        ),
      ),
    );
  }

  void _showModalBottomSheet() {
    showModalBottomSheet(
            context: context,
            builder: (context) {
              return ClipRRect(
                borderRadius: BorderRadius.circular(30),
                child: ListView(
                  children: [
                    const SizedBox(height: 20),
                    button(
                      text: "to 0.7",
                      onPressed: () => animationController.animateTo(0.7),
                    ),
                    button(
                      text: "to 0.3",
                      onPressed: () => animationController.animateTo(0.3),
                    ),
                    button(
                      text: "back 0.6",
                      onPressed: () => animationController.animateBack(0.6),
                    ),
                    button(
                      text: "back 0.2",
                      onPressed: () => animationController.animateBack(0.2),
                    ),
                    ...List.generate(
                      10,
                      (index) => Container(
                        height: 100,
                        width: double.infinity,
                        margin: const EdgeInsets.all(20),
                        color: Colors.white,
                        alignment: Alignment.center,
                        child: Text(
                          "index : $index",
                          style: const TextStyle(fontSize: 20),
                        ),
                      ),
                    )
                  ],
                ),
              );
            },
            elevation: 50,

            /// 바텀시트 드래그 가능 여부
            enableDrag: true,

            /// 바텀시트가 아닌 부분을 클릭했을 때
            /// 바텀시트를 닫을지 말지 설정
            isDismissible: true,

            /// 바텀시트 아닌 영역의 컬러
            barrierColor: Colors.grey.withOpacity(0.3),

            /// 바텀시트 배경 컬러
            backgroundColor: Colors.blue.shade200,

            /// 사이즈 조절
            constraints: const BoxConstraints(
              minWidth: 100,
              maxWidth: 300,
              minHeight: 100,
              maxHeight: 500,
            ),

            /// 바텀시트의 모양 설정
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(30),
              side: const BorderSide(color: Colors.red, width: 5),
            ),
            // shape: CircleBorder(),
            /// false = 화면의 절반만 차지함
            /// true = 전체 화면 차이
            isScrollControlled: true,

            /// SafeArea 사용할지 말지 설정
            /// isScrollControlled을 true로 설정하면 상태바까지 올라감
            /// 이때 useSafeArea를 true로 설정하면 상태바는 사용 불가
            useSafeArea: true,
            transitionAnimationController: animationController

            /// timeout 기능 -> 입력한 Duration 이후 onTimeout 함수 호출됨
            )

        /// 7초 후  호출
        .timeout(const Duration(seconds: 7), onTimeout: () {})

        /// then -> 바텀시트 닫은 경우 호출됨
        .then((value) {})

        /// whenComplete -> then 다음에 호출됨
        .whenComplete(() {});
  }

  Widget button({required String text, required Function() onPressed}) {
    return ElevatedButton(
      onPressed: () {
        onPressed();
      },
      style: ElevatedButton.styleFrom(
        fixedSize: const Size(double.infinity, 50),
      ),
      child: Text(text),
    );
  }
}