An overwhelming majority of Flutter code tutorials show phone screens in portrait mode. But what about tablets and landscape mode? Android comes with a system to specify layouts per orientation – can you do this with Flutter?
When I created my first Flutter app Crosswords to learn French, I locked it to portrait mode. This was fine for v1.0, but for v1.1, I wanted to add tablet support. This meant allowing landscape mode. To my surprise, it took only about 30 minutes. You read that right, 30 minutes! As I had already put each UI section into its own widget, it was easy to recombine them based on orientation. And retrieving orientation turned out to be easily done, once I knew where to look ( MediaQuery and MediaQueryData).


Note: I chose to maximise crossword size, so I decided not to show the custom keyboard view across the full width of the screen, but, technically, I could have done it just as easily.
For this code tutorial, I will use a simplified version of my app UI. We will create the portrait layout, then the landscape layout.
Setting up the app
To follow the code tutorial, create a new app as follows.
1 |
flutter create landscapeexample |
If you’re unsure how to set up a Flutter app, check out Getting started with Flutter official tutorial.
Firstly, we create a Material app in main.dart, which will launch the HomePage widget.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import 'package:flutter/material.dart'; import 'home_page.dart'; void main() { runApp(new MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return new MaterialApp( title: 'Landscape Layout Example', theme: new ThemeData( primaryColor: const Color(0xFF43a047), accentColor: const Color(0xFFffcc00), primaryColorBrightness: Brightness.dark, ), home: new HomePage(), ); } } |
Secondly, we create home_page.dart. This will eventually display 3 views: crossword, clues, and keyboard. But for now, it just displays a message.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { HomePage({Key key}) : super(key: key); @override _HomePageState createState() => new _HomePageState(); } class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text("Landscape Layout example"), ), body: _buildBody(), ); } Widget _buildBody() { return new Text('Tutorial in progress'); } } |
Set up the views for portrait mode
Each logical view of the screen, ie crossword, clues, and keyboard, is set up in its own Widget. For simplicity, they are all stateless.
Firstly, we create crossword_view.dart. In this simplified version, it is a grid with 8 rows and 8 columns, with cells showing as a black or white square.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
import 'package:flutter/material.dart'; const double _MARGIN_HORIZONTAL = 30.0; const double _MARGIN_VERTICAL = 4.0; const Color _CELL_BACKGROUND = const Color(0xFFFFFFFF); const Color _BLANK_CELL_BACKGROUND = const Color(0xFF000000); const double _CELL_SPACING = 2.0; class CrosswordView extends StatelessWidget { CrosswordView({Key key, this.gridElements}) : super(key: key); // A simplified way to represent the list of cells in the grid. In practice, // you would want more complex data than an integer to represent each cell // in a crossword, but this is beyond the scope of this tutorial. List<int> gridElements; @override Widget build(BuildContext context) { Widget grid = new GridView.count( crossAxisCount: 8, shrinkWrap: true, childAspectRatio: 1.0, // This means each cell is a square padding: const EdgeInsets.only(left: _MARGIN_HORIZONTAL, right: _MARGIN_HORIZONTAL, top: _MARGIN_VERTICAL, bottom: _MARGIN_VERTICAL), mainAxisSpacing: _CELL_SPACING, crossAxisSpacing: _CELL_SPACING, children: gridElements.map((int cell) { return new Container( color: cell == 0?_BLANK_CELL_BACKGROUND: _CELL_BACKGROUND, ); }).toList()) ; return new Container( color: const Color(0x11000000), child: grid, ); } } |
Secondly, we create clues_view.dart. This contains 2 lists of different length, side by side, with equal width. Above each list, there is a non scrollable label that says either ‘Across’ or ‘Down’. It fits the available width and height.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
import 'package:flutter/material.dart'; class CluesView extends StatelessWidget { CluesView({Key key, this.acrossClues, this.downClues}) : super(key: key); List<String> acrossClues; List<String> downClues; @override Widget build(BuildContext context) { Widget cluesRowTitle = new Row( children: <Widget>[ new Expanded( child: new Text('Across', style: new TextStyle(fontWeight: FontWeight.bold),), ) , new Expanded( child: new Text('Down', style: new TextStyle(fontWeight: FontWeight.bold),), ) , ], ); Widget acrossCluesWidget = new ListView( scrollDirection: Axis.vertical, shrinkWrap: true, children: acrossClues.map((String clue) { return new Text(clue); }).toList()); Widget downCluesWidget = new ListView( scrollDirection: Axis.vertical, shrinkWrap: true, children: downClues.map((String clue) { return new Text(clue); }).toList()); Widget cluesRow = new Row(children: <Widget>[ new Expanded( child: acrossCluesWidget, ) , new Expanded( child: downCluesWidget, ) , ], ); return new Container( padding: const EdgeInsets.all(8.0), child: new Column( children: <Widget>[ new Container( height: 28.0, child: cluesRowTitle, ), new Expanded( child: cluesRow, ), ], ), ); } } |
Thirdly, we create keyboard_view.dart. This contains 3 rows of fixed and equal height. It fills the available width.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import 'package:flutter/material.dart'; const double _ROW_HEIGHT = 40.0; const Color _KEY_BACKGROUND = const Color(0x33000000); class KeyboardView extends StatelessWidget { KeyboardView({Key key}) : super(key: key); @override Widget build(BuildContext context) { return new Column( children: <Widget>[ new Container( height: _ROW_HEIGHT, color: _KEY_BACKGROUND, ), new Container( height: 1.0, ), new Container( height: _ROW_HEIGHT, color: _KEY_BACKGROUND, ), new Container( height: 1.0, ), new Container( height: _ROW_HEIGHT, color: _KEY_BACKGROUND, ) ], ); } } |
Finally, we combine the views together, for portrait mode. Crossword view has a defined height because shrinkWrap is set to true; keyboard view has a fixed height; clues view fits the rest of the height. Let’s amend home_page.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
import 'package:flutter/material.dart'; import 'crossword_view.dart'; import 'clues_view.dart'; import 'keyboard_view.dart'; [...] class _HomePageState extends State<HomePage> { List<int> _gridElements; List<String> _acrossClues; List<String> _downClues; @override void initState() { super.initState(); // This is a shortcut to specify data. // In practice, you should load this from a data repository. _gridElements = new List<int>(); for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) { if (i + j == 6 || i + j == 3) { _gridElements.add(0); } else { _gridElements.add(1); } } } _acrossClues = new List<String>(); _acrossClues.add('Hello'); _acrossClues.add('Another one'); _acrossClues.add('Cryptic'); _downClues = new List<String>(); _downClues.add('Yellow'); _downClues.add('Brown'); _downClues.add('Sweet'); _downClues.add('However'); _downClues.add('Tunnel'); _downClues.add('Train'); _downClues.add('On your desk'); _downClues.add('Nursery'); _downClues.add('On the loose'); _downClues.add('Oliver Twist'); _downClues.add('Last one'); } [...] Widget _buildBody() { return new Column( children: <Widget>[ new CrosswordView(gridElements: _gridElements), new Expanded( child: new CluesView( acrossClues: _acrossClues, downClues: _downClues), ), new KeyboardView(), ], ); } } |
For a reminder on how to use Row and Column, check out Flutter UI code tutorial: mastering Row and Column.
Adapt layout to landscape mode
In Flutter, all UI code is done in Dart, in the build method. So we amend the _buildBody() method in home_page.dart to check the device orientation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Widget _buildBody() { Orientation orientation = MediaQuery .of(context) .orientation; if (orientation == Orientation.portrait) { return new Column( children: <Widget>[ new CrosswordView(gridElements: _gridElements), new Expanded( child: new CluesView( acrossClues: _acrossClues, downClues: _downClues), ), new KeyboardView(), ], ); } else { return new Text('Landscape layout'); } } |
Now, we can combine the views together for landscape mode.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
Widget _buildBody() { Orientation orientation = MediaQuery .of(context) .orientation; if (orientation == Orientation.portrait) { return new Column( children: <Widget>[ new CrosswordView(gridElements: _gridElements), new Expanded( child: new CluesView( acrossClues: _acrossClues, downClues: _downClues), ), new KeyboardView(), ], ); } else { return new Padding( padding: new EdgeInsets.symmetric(vertical: 0.0, horizontal: 0.0), child: new Row( children: <Widget>[ new CrosswordView(gridElements: _gridElements), new Expanded(child: new Column( children: <Widget>[ new Expanded( child: new CluesView(acrossClues: _acrossClues, downClues: _downClues),), new KeyboardView(), ], ), ), ], ), ); } } } |
BUT… it isn’t quite finished yet! The GridView used in crossword view is scrollable, and therefore fills in the available space. The property shrinkWrap forces it to constraint to its content the dimension in the direction of its scrollable axis. Put simply, if it is scrollable vertically (the default), it will set its height to fit the content, which works great in portrait mode. But in landscape mode, the dimension we need to constraint is the width; if we don’t, the Row widget will throw an error. So we need to check for the orientation in crossword_view.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
[...] class CrosswordView extends StatelessWidget { CrosswordView({Key key, this.gridElements}) : super(key: key); [...] @override Widget build(BuildContext context) { Orientation orientation = MediaQuery .of(context) .orientation; bool portrait = orientation == Orientation.portrait; Widget grid = new GridView.count( crossAxisCount: 8, shrinkWrap: true, childAspectRatio: 1.0, // This means each cell is a square padding: const EdgeInsets.only(left: _MARGIN_HORIZONTAL, right: _MARGIN_HORIZONTAL, top: _MARGIN_VERTICAL, bottom: _MARGIN_VERTICAL), mainAxisSpacing: _CELL_SPACING, crossAxisSpacing: _CELL_SPACING, scrollDirection: portrait? Axis.vertical:Axis.horizontal, children: gridElements.map((int cell) { return new Container( color: cell == 0?_BLANK_CELL_BACKGROUND: _CELL_BACKGROUND, ); }).toList()) ; return new Container( color: const Color(0x11000000), child: grid, ); } } |
Voila


What next?
This tutorial shows how to detect the orientation. Flutter Rocks has an excellent tutorial showing how to bring screen size qualifiers to Flutter, in Implementing adaptive master-detail layouts in Flutter.

Mobile app developer with 12 years experience. Started with the Android SDK in 2010 and switched to Flutter in 2017. Past apps range from start ups to large tech (Google) and non tech (British Airways) companies, in various sectors (transport, commercial operations, e-commerce).