Modal Floating Bottom Sheet in Flutter

A simple floating bottom slide up sheet implemented in Flutter using minimal packages for iOS and Android.

Sun, July 24 2022

jake hockey

Jake Landers

Developer and Creator

hey

This is some simple UI for an item picker utilizing a slide up sheet. This can be especially useful when you have multiple options that do not quite fit on one page, but do not want to navigate to a new screen. And, the slide up looks good.

Dependencies

For this project, you will need the amazing package Sprung for open and closing animation, and modal_bottom_sheet for some built in functionality when designing our own floating sheet.

1sprung: ^3.0.0 2modal_bottom_sheet: ^2.0.0 3

Sheet

First, we need to define a sheet view. This will leverage the modal bottom sheet package for some presentation functionality. This code is pretty much copy and paste, feel free to use it however you would like (without charging for usage).

1import 'dart:io'; 2import 'package:flutter/material.dart'; 3import 'package:flutter/widgets.dart'; 4import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; 5import 'package:sprung/sprung.dart'; 6 7/// Shows a floating sheet with padding based on the platform 8class FloatingSheet extends StatelessWidget { 9 final Widget child; 10 final Color? backgroundColor; 11 12 const FloatingSheet({ 13 Key? key, 14 required this.child, 15 this.backgroundColor, 16 }) : super(key: key); 17 18 19 Widget build(BuildContext context) { 20 return Padding( 21 padding: EdgeInsets.fromLTRB(10, 0, 10, Platform.isIOS ? 50 : 10), 22 child: Material( 23 color: backgroundColor, 24 clipBehavior: Clip.antiAlias, 25 shape: 26 ContinuousRectangleBorder(borderRadius: BorderRadius.circular(35)), 27 child: child, 28 ), 29 ); 30 } 31} 32 33/// Presents a floating model. 34Future<T> showFloatingSheet<T>({ 35 required BuildContext context, 36 required WidgetBuilder builder, 37 Color? backgroundColor, 38 bool useRootNavigator = false, 39 Curve? curve, 40}) async { 41 final result = await showCustomModalBottomSheet( 42 context: context, 43 builder: builder, 44 animationCurve: curve ?? Sprung.overDamped, 45 duration: const Duration(milliseconds: 700), 46 containerWidget: (_, animation, child) => FloatingSheet( 47 child: child, 48 backgroundColor: backgroundColor, 49 ), 50 expand: false, 51 useRootNavigator: useRootNavigator, 52 ); 53 54 return result; 55} 56
NOTE:

show_sheet.dart

Global Helpers

Whenever I have some attributes I would like to use over and over in a project, I like to define them as global functions. This same functionality can be accomplished with the style classes, but I find a functional programming approach is much easier to understand from a code readability standpoint.

This will give us access to a basic text color we can use: white when darkmode black when lightmode. A background color that is a bit softer than black or white, and the sheet color, which is a ligher variation of the sheet

1Color textColor(BuildContext context) { 2 return MediaQuery.of(context).platformBrightness == Brightness.light 3 ? Colors.black 4 : Colors.white; 5} 6 7Color backgroundColor(BuildContext context) { 8 return MediaQuery.of(context).platformBrightness == Brightness.light 9 ? const Color.fromRGBO(240, 240, 250, 1) 10 : const Color.fromRGBO(40, 40, 40, 1); 11} 12 13Color sheetColor(BuildContext context) { 14 return MediaQuery.of(context).platformBrightness == Brightness.light 15 ? Colors.white 16 : const Color.fromRGBO(40, 40, 40, 1); 17} 18

Sheet Selector

Now for the actual view.

I gave myself a stateful class with the following properties:

  • String title
  • T selection
  • Function(T) onSelection
  • List<T> available
  • List<String> titles
  • Color color
  • Color selectedTextColor

We can use <T> to define a dynamic type that will get set by the user using the widget. This allows us to handle mutliple primative types such as String, int, double, bool etc. There is also an optional titles list that lets you specify a human friendly name to your selections. This is helpful when selecting from a list of numbers. This requires a list of the same length as available, and the titles will be mapped in the same order as the available list.

This looks like:

1import 'package:flutter/material.dart'; 2import 'package:flutter/cupertino.dart'; 3import 'root.dart'; 4 5class SheetSelector<T> extends StatefulWidget { 6 SheetSelector({ 7 Key? key, 8 required this.title, 9 required this.selection, 10 required this.onSelect, 11 required this.available, 12 this.titles, 13 this.color = Colors.blue, 14 this.selectedTextColor = Colors.white, 15 }) : super(key: key); 16 final String title; 17 T selection; 18 final Function(T) onSelect; 19 final List<T> available; 20 final List<String>? titles; 21 final Color color; 22 final Color selectedTextColor; 23 24 25 _SheetSelectorState<T> createState() => _SheetSelectorState<T>(); 26} 27 28class _SheetSelectorState<T> extends State<SheetSelector<T>> { 29 30 void initState() { 31 // assert that selections and titles are the same length 32 if (widget.titles != null) { 33 if (widget.titles!.length != widget.available.length) { 34 throw "Available selections list and titles need to be the same length"; 35 } 36 } 37 super.initState(); 38 } 39 ... 40} 41

Then, I defined a cell that will render with a background color when selected and a transparent one when not selected. This takes a T value which is a dynamic type specified by the user, and a title.

1Widget _cell(BuildContext context, T val, String title) { 2 return CupertinoButton( 3 color: Colors.transparent, 4 disabledColor: Colors.transparent, 5 padding: const EdgeInsets.all(0), 6 minSize: 0, 7 onPressed: () { 8 setState(() { 9 widget.onSelect(val); 10 widget.selection = val; 11 }); 12 }, 13 // rounded container with half height radius for complete circle effect. 14 child: Container( 15 decoration: BoxDecoration( 16 borderRadius: BorderRadius.circular(25), 17 color: val == widget.selection ? widget.color : Colors.transparent, 18 ), 19 width: double.infinity, 20 height: 50, 21 child: Center( 22 child: Text( 23 title, 24 style: TextStyle( 25 fontSize: 16, 26 fontWeight: FontWeight.w500, 27 color: val == widget.selection 28 ? widget.selectedTextColor 29 : textColor(context), 30 ), 31 ), 32 ), 33 ), 34 ); 35} 36

Next, I designed a list that holds the available cells. This dynamically handles whether there is a title list or not and provides the right data to _cell

1Widget _selector(BuildContext context) { 2 return Column( 3 mainAxisSize: MainAxisSize.min, 4 children: [ 5 for (int index = 0; index < widget.available.length; index++) 6 Padding( 7 padding: const EdgeInsets.symmetric(horizontal: 16.0), 8 child: Column( 9 children: [ 10 _cell( 11 context, 12 widget.available[index], 13 widget.titles != null 14 ? widget.titles![index] 15 : widget.available[index].toString()), 16 if (index < widget.available.length) const SizedBox(height: 16), 17 ], 18 ), 19 ), 20 ], 21 ); 22} 23

Now we can arrange this view into a nice package with a header into the build method like:

1 2Widget build(BuildContext context) { 3 return Column( 4 mainAxisSize: MainAxisSize.min, 5 children: [ 6 // header 7 _header(context), 8 const SizedBox(height: 16), 9 _selector(context), 10 ], 11 ); 12} 13 14Widget _header(BuildContext context) { 15 return Container( 16 width: double.infinity, 17 height: 45, 18 color: MediaQuery.of(context).platformBrightness == Brightness.light 19 ? Colors.black.withOpacity(0.1) 20 : Colors.white.withOpacity(0.1), 21 // wrap with a stack to allow for centered title with button on right side 22 child: Stack( 23 alignment: AlignmentDirectional.center, 24 children: [ 25 // title widget 26 Text( 27 widget.title, 28 style: TextStyle( 29 fontSize: 20, 30 fontWeight: FontWeight.w600, 31 color: textColor(context), 32 ), 33 ), 34 // push button to the left side 35 // not using expanded, sometimes button becomes clickable across entire width 36 Row(children: [ 37 const Spacer(), 38 Padding( 39 padding: const EdgeInsets.only(right: 8.0), 40 // edited cupertino button to only show slightly opaqued when tapped. No other styling 41 child: CupertinoButton( 42 color: Colors.transparent, 43 disabledColor: Colors.transparent, 44 padding: const EdgeInsets.all(0), 45 minSize: 0, 46 onPressed: () { 47 // close the view 48 Navigator.of(context).pop(); 49 }, 50 child: Text( 51 "Close", 52 style: TextStyle( 53 fontSize: 18, 54 fontWeight: FontWeight.w500, 55 color: widget.color, 56 ), 57 ), 58 ), 59 ), 60 ]) 61 ], 62 ), 63 ); 64} 65

Main View

Here is an example page to show the functionality of the widget

1class Main extends StatefulWidget { 2 const Main({Key? key}) : super(key: key); 3 4 5 _MainState createState() => _MainState(); 6} 7 8class _MainState extends State<Main> { 9 final List<String> _titles = ["Hello", "I", "Am", "Jake"]; 10 late String _selectedTitle; 11 final List<int> _ids = [1, 2, 3, 4, 5]; 12 late int _selectedId; 13 14 15 void initState() { 16 _selectedTitle = _titles.first; 17 _selectedId = _ids.first; 18 super.initState(); 19 } 20 21 22 Widget build(BuildContext context) { 23 return Scaffold( 24 backgroundColor: backgroundColor(context), 25 body: ListView( 26 children: [ 27 Padding( 28 padding: const EdgeInsets.all(16.0), 29 child: SafeArea( 30 child: Column( 31 children: [ 32 Row( 33 children: [ 34 Text( 35 "Custom Selector", 36 style: TextStyle( 37 fontWeight: FontWeight.w600, 38 fontSize: 32, 39 color: textColor(context), 40 ), 41 ), 42 ], 43 ), 44 const SizedBox(height: 32), 45 // example using strings 46 Center( 47 child: CupertinoButton( 48 color: Colors.transparent, 49 disabledColor: Colors.transparent, 50 padding: const EdgeInsets.all(0), 51 minSize: 0, 52 onPressed: () { 53 showFloatingSheet( 54 context: context, 55 backgroundColor: sheetColor(context), 56 builder: (context) { 57 return SheetSelector<String>( 58 title: "Select Title", 59 selection: _selectedTitle, 60 available: _titles, 61 onSelect: (value) { 62 setState(() { 63 _selectedTitle = value; 64 }); 65 }, 66 ); 67 }, 68 ); 69 }, 70 child: Text( 71 _selectedTitle, 72 style: TextStyle( 73 color: textColor(context), 74 fontSize: 20, 75 fontWeight: FontWeight.w500, 76 decoration: TextDecoration.underline, 77 ), 78 ), 79 ), 80 ), 81 const SizedBox(height: 32), 82 // example using integer and titles 83 Center( 84 child: CupertinoButton( 85 color: Colors.transparent, 86 disabledColor: Colors.transparent, 87 padding: const EdgeInsets.all(0), 88 minSize: 0, 89 onPressed: () { 90 showFloatingSheet( 91 context: context, 92 backgroundColor: sheetColor(context), 93 builder: (context) { 94 return SheetSelector<int>( 95 title: "Select ID", 96 selection: _selectedId, 97 available: _ids, 98 onSelect: (value) { 99 setState(() { 100 _selectedId = value; 101 }); 102 }, 103 titles: const [ 104 "One", 105 "Two", 106 "Three", 107 "Four", 108 "Five" 109 ], 110 ); 111 }, 112 ); 113 }, 114 child: Text( 115 _selectedId.toString(), 116 style: TextStyle( 117 color: textColor(context), 118 fontSize: 20, 119 fontWeight: FontWeight.w500, 120 decoration: TextDecoration.underline, 121 ), 122 ), 123 ), 124 ), 125 ], 126 ), 127 ), 128 ), 129 ], 130 ), 131 ); 132 } 133} 134

Comments