-
-
Save slightfoot/7240771f44634775b6afb2b7b9ea0455 to your computer and use it in GitHub Desktop.
Bottom sheet with text fields - by Jamil Saadeh & Simon Lightfoot :: #HumpdayQandA on 22nd October 2025 :: https://www.youtube.com/watch?v=NJE_v43Drug
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MIT License | |
| // | |
| // Copyright (c) 2025 Jamil Saadeh & Simon Lightfoot | |
| // | |
| // Permission is hereby granted, free of charge, to any person obtaining a copy | |
| // of this software and associated documentation files (the "Software"), to deal | |
| // in the Software without restriction, including without limitation the rights | |
| // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| // copies of the Software, and to permit persons to whom the Software is | |
| // furnished to do so, subject to the following conditions: | |
| // | |
| // The above copyright notice and this permission notice shall be included in all | |
| // copies or substantial portions of the Software. | |
| // | |
| // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| // SOFTWARE. | |
| // | |
| import 'package:flutter/material.dart'; | |
| // This is an example of a bottom sheet with a text field inside a tab | |
| // and a footer with another text field. | |
| // Please use a tablet simulator on landscape mode to better see the issue. | |
| // When the text field of tab 1 is pressed and focused, the keyboard appears and | |
| // resizes the bottom sheet correctly. However, the footer go over the whole content of tab 1 which | |
| // causes the user to not see what's being written. | |
| // I came up with several workarounds but I don't feel it's the right way to handle that kind of situation. | |
| // It really felt too "hacky" | |
| // My current workarounds: | |
| // 1- I tried switching the visibility off of the footer when the tab1's text field is focused. | |
| // 2- I tried addind a "resizeToAvoidBottomInset: false" to the Scaffold of the bottom sheet and | |
| // adding a listener to the focus node of the footer text field. | |
| // Whenever it is focused, I set the bottom padding to MediaQuery.viewInsetsOf(context).bottom | |
| // This way, only when the footer text field is focused, the footer is "lifted up" to avoid the keyboard. | |
| // When the tab1's text field is focused, nothing is being resized. | |
| // Any suggestions or pointers would be appreciated. Thank you | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'Flutter Demo', | |
| theme: ThemeData.dark(), | |
| home: const MyHomePage(title: 'Bottom sheet demo'), | |
| ); | |
| } | |
| } | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({super.key, required this.title}); | |
| final String title; | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| class _MyHomePageState extends State<MyHomePage> { | |
| void _onPressed() { | |
| showModalBottomSheet( | |
| context: context, | |
| useSafeArea: true, | |
| isScrollControlled: true, | |
| isDismissible: true, | |
| builder: (context) => SomeBottomSheet(), | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar(title: Text(widget.title)), | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| ElevatedButton( | |
| onPressed: _onPressed, | |
| child: Text('Open bottom sheet'), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class SomeBottomSheet extends StatelessWidget { | |
| const SomeBottomSheet({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return GestureDetector( | |
| onTap: () => FocusManager.instance.primaryFocus?.unfocus(), | |
| child: Scaffold( | |
| resizeToAvoidBottomInset: false, | |
| body: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| Expanded(child: SomeBody()), | |
| FocusScope( | |
| child: SomeFooter(), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class SomeBody extends StatelessWidget { | |
| const SomeBody({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return DefaultTabController( | |
| length: 2, | |
| child: Column( | |
| children: [ | |
| TabBar( | |
| tabs: [ | |
| Tab(text: 'Tab 1'), | |
| Tab(text: 'Tab 2'), | |
| ], | |
| ), | |
| Expanded( | |
| child: Padding( | |
| padding: const EdgeInsets.all(8.0), | |
| child: TabBarView(children: [Tab1Content(), Tab2Content()]), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| class Tab1Content extends StatelessWidget { | |
| const Tab1Content({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return SingleChildScrollView( | |
| child: Column( | |
| spacing: 20, | |
| children: [ | |
| TextField(decoration: InputDecoration(labelText: 'Some label')), | |
| ElevatedButton(onPressed: () {}, child: Text('Submit')), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| /// Demonstration purposes only - close to my real use case | |
| class Tab2Content extends StatelessWidget { | |
| const Tab2Content({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return ListView.separated( | |
| itemCount: 20, | |
| separatorBuilder: (context, index) => Divider(), | |
| itemBuilder: (context, index) => ListTile(title: Text('Item ${index + 1}')), | |
| ); | |
| } | |
| } | |
| class SomeFooter extends StatelessWidget { | |
| const SomeFooter({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return ViewInsetsPadding( | |
| enabled: FocusScope.of(context).hasFocus, | |
| child: DecoratedBox( | |
| decoration: BoxDecoration( | |
| color: Colors.black45, | |
| borderRadius: BorderRadius.circular(12), | |
| ), | |
| child: Padding( | |
| padding: const EdgeInsets.all(8.0), | |
| child: Column( | |
| children: [ | |
| TextField( | |
| decoration: InputDecoration(labelText: 'Footer input'), | |
| ), | |
| SizedBox(height: 75), // This is a placeholder for any content. | |
| ElevatedButton(onPressed: () {}, child: Text('Footer button')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class ViewInsetsPadding extends StatelessWidget { | |
| const ViewInsetsPadding({ | |
| super.key, | |
| required this.enabled, | |
| required this.child, | |
| }); | |
| final bool enabled; | |
| final Widget child; | |
| @override | |
| Widget build(BuildContext context) { | |
| final viewInsets = MediaQuery.viewInsetsOf(context); | |
| return Padding( | |
| padding: | |
| enabled // | |
| ? EdgeInsets.only(bottom: viewInsets.bottom) | |
| : EdgeInsets.zero, | |
| child: child, | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment