Skip to content

Latest commit

 

History

History
755 lines (586 loc) · 29.5 KB

03.md

File metadata and controls

755 lines (586 loc) · 29.5 KB

Chapter 3: Essential Flutter Widgets

In the previous chapters, we've gotten familiar with Dart, the basic structure of a starter Flutter project, and the three different trees driving the framework.

Now we'll take a look at some fundamental widgets that you'll encounter in many Flutter projects along with basic UI elements and layouts.

To start each example from the chapter's demo project, we can use the flutter run -t lib/main.dart command, changing main.dart to the path of the file we want to be the entry point of the app.

In Android Studio, we can create a run configuration for each entry point file. This way we can easily tell Android Studio and the Flutter tools which file should they consider the entry point of the app when building and running it from the UI.

03_android_studio_run_configs.png

While such run configurations are great to run these examples from the UI of Android Studio, running them from the command line is still possible with the following commands as well - if issued from the project's root folder of course:

flutter run -t /lib/00_widgets_app/widgets_app.dart

flutter run -t /lib/01_material_app/material_app.dart

flutter run -t /lib/02_cupertino_page_app/cupertino_page_app.dart

flutter run -t /lib/03_cupertino_tab_app/cupertino_tab_app.dart

flutter run -t /lib/04_comon_ui_widgets/common_ui_widgets.dart

The App Widgets

While we definitely could build every aspect of a Flutter app ourselves from the ground up, it doesn't mean we really should go down that path.

The WidgetsApp widget is there for us to provide basic functionality for our apps.

By using a WidgetsApp as the direct child of our app widget, it helps to implement the following functionalities:

  • Defining the default text style for every widget in the app
  • Handling localization
  • Navigation between pages (screens)
  • Handling overlays, like dialogs that can be displayed over other content

WidgetsApp seems to be a class with a lot of responsibilities, but it delegates these responsibilities to other widgets under the hood, like DefaultTextStyle, or Navigator.

If our goal was to build a game with widgets only or to build a regular app with our own design language, WidgetsApp pretty much has us covered.

A really simple app with a single page built with WidgetsApp looks like this:

import 'package:flutter/widgets.dart';

void main() {
  runApp(const WidgetsAppDemo());
}

class WidgetsAppDemo extends StatelessWidget {
  const WidgetsAppDemo({Key? key}) : super(key: key);

  Route generateRoutes(RouteSettings settings) {
    return PageRouteBuilder(
      pageBuilder: (BuildContext context, Animation<double> animation,
          Animation<double> secondaryAnimation) {
        return const WidgetsAppDemoPage();
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return WidgetsApp(
      title: 'WidgetsApp Demo',
      // Theming is very limited with WidgetsApp only.
      color: const Color.fromARGB(255, 0, 255, 0),
      initialRoute: "/",
      // If we use multiple pages, we have to define exactly how navigation should happen between them.
      onGenerateRoute: generateRoutes,
      textStyle: const TextStyle(
        color: Color.fromARGB(255, 0, 0, 0),
      ),
    );
  }
}

class WidgetsAppDemoPage extends StatelessWidget {
  const WidgetsAppDemoPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Container(
        color: const Color.fromARGB(255, 255, 255, 255),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 24.0),
              child: Text(
                "The Main Menu of a\nLegendary Game",
                textAlign: TextAlign.center,
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 8.0),
              child: GestureDetector(
                child: const Text(
                  "New Game",
                  textAlign: TextAlign.center,
                ),
                onTap: () {
                  showGeneralDialog(
                    context: context,
                    pageBuilder: (BuildContext context,
                        Animation<double> animation,
                        Animation<double> secondaryAnimation) {
                      return Center(
                        child: Container(
                          width: 200,
                          height: 200,
                          color: const Color.fromARGB(255, 255, 255, 255),
                          child: const SizedBox(
                            width: 200,
                            height: 200,
                            child: Center(
                              child: Text("New Game clicked"),
                            ),
                          ),
                        ),
                      );
                    },
                  );
                },
              ),
            ),
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 8.0),
              child: Text(
                "Load Game",
                textAlign: TextAlign.center,
              ),
            ),
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 8.0),
              child: Text(
                "Exit",
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Running the app gives a plain black and white UI:

03_widgetsapp_app.png

See 00_widgets_app.dart.

However, Flutter has built-in support for Material Design, Google's all-around design language, and even for Apple's Human Interface Guidelines on iOS. Flutter's implementation of the latter is called Cupertino.

As we already know from the previous chapters, Flutter is using the supported platforms' native canvases to render UI widgets. This means that technically we can build an app using Cupertino UI widgets only, and the app will run on all supported platforms, though due to licensing issues, Cupertino won't have the correct fonts on any other platform than iOS or macOS.

Let's take a closer look at some app building blocks from the aforementioned implementations.

Material Design app basics

MaterialApp

MaterialApp is a StatefulWidget wrapping many other widgets that are required for material design applications. It builds upon and extends the functionality of WidgetsApp.

One of the extra functionalities that MaterialApp offers is Material-specific navigation handling (navigation meaning navigating between pages). We'll get back to discussing navigation in Chapter 7 of this material.

MaterialApp is also responsible for providing default theme and style values for Material widgets.

ThemeData

We can use ThemeData objects to configure themes for Material apps.

Material theme colors (like primaryColor or accentColor) can be defined individually in a ThemeData, but swatches can also be used. A swatch is a collection of color shades generated (and for built-in swatches, hardcoded) from a base color. In other words, a swatch is a color palette.

The official documentation of the Material color system goes into further details about color palettes. Also, if we wanted to create our own Material color palette, we could use the color tool.

Apart from the main theme colors, we can customize most of the Material widgets' colors individually with a ThemeData, as well as global text and icon themes.

Scaffold

Scaffold implements the basic layout structure of a Material Design page. Although it has many slots that accept Widgets, some widgets are designed to be used together with Scaffold to make creating standard Material pages a breeze.

A Scaffold can manage the following Material components (the list is not exhaustive):

The middle area of a Scaffold is called the body. We can use the body of a Scaffold to display the contents of a page and - not very surprisingly - we can use any Widget to do so.

The basic Counter example app already builds upon Scaffold, filling the appBar, body, and floatingActionButton slots.

AppBar

The AppBar widget implements a standard Material app bar (also known as a Toolbar, previously known as an ActionBar).

The following example demonstrates how we can build a Scaffold and an AppBar:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialDemoApp());
}

class MaterialDemoApp extends StatelessWidget {
  const MaterialDemoApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.green,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: Scaffold(
        appBar: AppBar(
          leading: const Text("Leading widget"),
          title: const Text("App title"),
          actions: const [
            Text("Action1"),
            Text("Action2")
          ],
        ),
      ),
    );
  }
}

03_material_appbar.png

We have full control over the content of an AppBar's slots. Instead of a back navigation arrow or a hamburger menu icon, we can use any Widget to define the leading content, and the title can also be any Widget, not just Text.

While we're free to build any widget hierarchy for AppBars slots, some widgets play nicely with AppBar by design:

  • Icon
  • IconButton
  • PopupMenuButton

We'll discuss widgets implementing more Material Design UI elements in Chapter 7.

Cupertino app basics

Now, let's get familiar with the basic structure of Cupertino pages.

CupertinoApp

The CupertinoApp widget has the same responsibilities as its Material counterpart. One significant difference between the behaviors of CupertinoApp and MaterialApp surfaces when we don't define the top-level themes explicitly. MaterialApp's theme defaults to the value of ThemeData.light(), while CupertinoApp will use colors provided by iOS.

CupertinoThemeData

As the Human Interface Guidelines' Color section suggests, CupertinoThemeData has much less flexibility compared to Material's ThemeData. We can only define four colors and the theme of the texts.

CupertinoPageScaffold and CupertinoTabScaffold

There are two kinds of scaffold for the Cupertino framework:

CupertinoPageScaffold has a navigation bar slot at the top of the page, and a body slot (called child) for the page content.

CupertinoTabScaffold implements a typical iOS style page with support for bottom navigation tabs.

The navigation bar implementation is called CupertinoNavigationBar, and the bottom navigation tab implementation is called CupertinoTabBar.

A CupertinoPageScaffold example:

CupertinoPageScaffold(
  navigationBar: const CupertinoNavigationBar(
    // leading: CupertinoNavigationBarBackButton(previousPageTitle: "Back",), // Only works when a valid navigation route exists to a previous page
    middle: Text("Page title"),
    trailing: Icon(CupertinoIcons.info),
  ),
  child: Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text("App body"),
        CupertinoButton(
          child: const Text("Press me"),
          onPressed: () {
            showCupertinoDialog(
              context: context,
              builder: (_) => CupertinoAlertDialog(
                title: const Text("Good job!"),
                content: const Text("Now press OK"),
                actions: [
                  CupertinoDialogAction(
                    isDefaultAction: true,
                    child: const Text("OK"),
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                  ),
                ],
              ),
            );
          },
        ),
      ],
    ),
  ),
);
03_cupertino_page_app.png 03_cupertino_page_app_2.png

A CupertinoTabScaffold example:

return CupertinoApp(
  title: "Cupertino Tabs Demo",
  theme: const CupertinoThemeData(),
  debugShowCheckedModeBanner: false,
  home: CupertinoTabScaffold(
    tabBar: CupertinoTabBar(
      items: const <BottomNavigationBarItem>[
        BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.home),
          label: "Products",
        ),
        BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.search),
          label: "Search",
        ),
        BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.shopping_cart),
          label: "Cart",
        ),
      ],
    ),
    tabBuilder: (context, index) {
      late CupertinoTabView returnValue;
      switch (index) {
        case 0:
          returnValue = CupertinoTabView(builder: (context) {
            return const CupertinoPageScaffold(
              child: Center(
                child: Text("Products"),
              ),
            );
          });
          break;
        case 1:
          returnValue = CupertinoTabView(builder: (context) {
            return const CupertinoPageScaffold(
              child: Center(
                child: Text("Search"),
              ),
            );
          });
          break;
        case 2:
          returnValue = CupertinoTabView(builder: (context) {
            return const CupertinoPageScaffold(
              child: Center(
                child: Text("Cart"),
              ),
            );
          });
          break;
      }
      return returnValue;
    },
  ),
);
03_cupertino_tab_app.png 03_cupertino_tab_app_2.png

At this point, for the sake of the length of this chapter and the whole course, we'll part with Cupertino. The Google codelab titled Building a Cupertino app with Flutter gives a detailed tutorial on how to build apps with Cupertino. Feel free to check it out.

From now on, the code samples and examples we'll be discussing will assume a Material app environment, as we can use the Material library on all supported platforms without any restrictions.

While Material Design is, to an extent, a viable concept for iOS apps too, keep in mind that iOS users have their own design system they are familiar with. This means that an app designed strictly with Android and Material in mind may not be appealing or even acceptable for iOS users.

In some cases, a considerable approach is to let both Material and Cupertino go (or mix and match them cleverly) and implement the app's own design language that works for most of the potential users.

There are many apps out there that tackled this challenge. Such apps can easily be found at https://itsallwidgets.com/.

With that in mind, let's have a look at some of the most commonly used UI and layout widgets in Flutter's arsenal.

Common UI Widgets

In this section, we'll see a few basic UI widgets which are essential building blocks of most Flutter apps.

Text

We've already seen the Text widget in action briefly in Chapter 2. Unsurprisingly, the Text widget can render a piece of text on the screen.

The easiest way to use Text is by simply passing a String as its only positional required parameter. In this case, the displayed text's properties are defined by the closest enclosing DefaultTextStyle.

Text is highly customizable. We can define custom TextStyle objects - or use existing ones from a Theme - to tell Text how we want it to render its content.

The following example shows some properties of TextStyle in action:

Text(
  '$_counter',
  style: const TextStyle(
    color: Colors.red,
    fontSize: 32,
    fontStyle: FontStyle.italic,
    fontWeight: FontWeight.bold,
    decoration: TextDecoration.underline,
    decorationStyle: TextDecorationStyle.dotted,
  ),
),

03_custom_text_style.png

Icon

While most UI frameworks have only one basic component for displaying any kind of image, Flutter differentiates between images and drawables that are meant to be used as icons.

The built-in icons themselves are provided as fonts (MaterialIcons and CupertinoIcons). Having uses-material-design: true and/or the cupertino_icons dependency in our project's pubspec.yaml makes the mentioned fonts available to use.

We can use the Icon widget to display such icons. Working with the Icon widget is similar to Text: the only positional required parameter is the icon to display (which can be null!). If not specified, all other parameters default to the ambient IconTheme if available. If an ambient IconTheme is not available, hardcoded default values apply.

Taking Icons color as an example, as stated in the official documentation:

"Defaults to the current IconTheme color, if any.

The given color will be adjusted by the opacity of the current IconTheme if any.

In material apps, if there is a Theme without any IconThemes specified, icon colors default to white if the theme is dark and black if the theme is light.

If no IconTheme and no Theme is specified, icons will default to black."

Icons passed to Icon are expected to be squared. Non-squared icons may render incorrectly.

The following example shows how to display the Android logo as an icon:

Icon(
  Icons.android,
  color: Colors.greenAccent,
  size: 40,
),

03_displaying_an_icon.png

Image

Most apps have to display some images in one way or another. Images can come from our app's asset bundle, the device's local storage, from the network, or an in-memory bitmap.

We can use the Image widget to display images from each of the said sources.

We've already seen how to include images in an app's asset bundle in Chapter 2.

Let's assume that there's a file named logo_flutter.png in our project's assets/images directory. To access the file as an image asset, we need to add either the file's or the directory's relative path to the project's pubspec.yaml file:

flutter:
  ...
  assets:
    - assets/images/ # <- Here

We could use Image's primary constructor to load logo_flutter.png by explicitly creating an AssetImage:

Image(
  image: AssetImage('assets/images/logo_flutter.png'),
),

Yet, there is a smarter, named constructor for loading images from assets, which attempts to load the pixel-density-aware asset, if available:

Image.asset(
  'assets/images/logo_flutter.png',
  height: 100,
),

03_image_flutter_logo.png

If we don't specify the exact height and width of an image, its content will be sized based on the fit parameter, which can be an instance of BoxFit (see the documentation for a detailed description of each fitting strategy).

Loading images from the default asset bundle is straightforward. So is loading images from the file system of the device by using the Image.file(File) constructor.

On the other hand, a more interesting topic is loading images from the network (by URLs). For example, Android needs 3rd party libraries (Picasso, Glide, Coil) to make image loading as easy as possible, and so does iOS.

With Flutter, asynchronously loading an image from a URL is as simple as it can be (without any extra parameters of course):

Image.network(
  "https://picsum.photos/150",
),

https://picsum.photos is the address of Lorem Picsum, a neat random placeholder image provider.

If our image provider backend needs extra headers (for example to authenticate the request), we can provide a Map<String, String> containing the required headers.

Image.network(
  "https://picsum.photos/150",
  headers: {
    "Authorization": "Bearer 0123456789"
  },
),

Image also supports displaying any widget we wish to display as a loading indicator:

Image.network(
  "https://picsum.photos/150",
  loadingBuilder: (BuildContext context, Widget child,
      ImageChunkEvent? loadingProgress) {
    if (loadingProgress == null) return child;
    return Center(
      child: CircularProgressIndicator(
        value: loadingProgress.expectedTotalBytes != null
            ? loadingProgress.cumulativeBytesLoaded /
                loadingProgress.expectedTotalBytes!
            : null,
      ),
    );
  },
),

03_image_network.png

Although the built-in Image widget can be really useful in simple situations, using the cached_network_image package is highly recommended, as it makes caching, and handling loading and error states even easier.

Displaying an image from memory only requires a list of the bytes of the image.

SizedBox

SizedBox is a widget with two main functionalities:

  • If it has a child, SizedBox forces its child to have the same dimensions as itself (if the dimension values are permitted by its parent - we'll explore this topic in Chapter 4 of this material).

    • This mode is useful for constraining the size of other widgets.
  • If it doesn't have a child, SizedBoxjust takes up the space on the screen but doesn't render anything.

    • This way, SizedBox can act as a spacer between other widgets.
SizedBox(
  width: 80,
  height: 20,
  child: Container(
    color: Colors.brown,
  )
),

03_sizedbox.png

Layout Widgets

In this section, we'll be discussing widgets that are responsible for the position and/or the size of their children. In other words, these widgets (along with many others) make up the layout of our pages most of the time.

We've already encountered some of them in Chapter 2 while exploring the Counter starter app.

Container

Container is a convenience widget that can provide padding, margin, positioning, and sizing to its child widget. It can also render background and foreground decorations.

The following code snippet demonstrates a small subset of what Container can do:

Container(
  margin: const EdgeInsets.all(16),
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    shape: BoxShape.rectangle,
    border: Border.all(
      color: Colors.black,
      width: 8,
    ),
    color: Colors.blueGrey,
    borderRadius: BorderRadius.circular(8)
  ),
  alignment: Alignment.center,
  child: ...
)
03_container.png

Container has a rather complex layout behavior which is detailed in its official documentation.

Center

Center does exactly what its name suggests. It positions its only child to the center of its own area. If constrained on both dimensions, Center will be as big as possible.

Center(
  child: const Text("Centered Text"),
),
03_center.png

Column

Column is a layout widget that displays its children vertically one after another. It's analogous to Android's LinearLayout with vertical orientation and iOS's vertical Stack View.

Column itself is not scrollable (but by default tries to occupy as much space as it can), thus building one with more children than what will fit into the available space is generally considered an error - in such cases, ListView is a better choice.

Again, Column is a layout widget that we've already seen in the Counter project.

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisSize: MainAxisSize.min,
  children: const [
    Text("First widget in a column"),
    Text("Second widget in a column"),
  ]
),

03_column.png

Row

Row is the horizontal variant of Column, the only difference being that Row's main axis is the horizontal axis.

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisSize: MainAxisSize.min,
  children: [
    Icon(
      Icons.android,
      color: Colors.greenAccent,
      size: 40,
    ),
    Icon(
      Icons.alarm,
      color: Colors.orangeAccent,
      size: 40,
    ),
  ]
),

03_row.png

Expanded

The Expanded widget works with Row and Column (and Flex, which we won't be discussing in this chapter) and makes its child fill the available space on the main axis of those widgets. Additionally, if multiple children on the same level are expanded, we can set the so-called flex factor on Expanded widgets to control the division of available space between them.

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [
    Expanded(
      flex: 3,
      child: Image.asset(
        'assets/images/logo_flutter.png',
        height: 100,
      ),
    ),
    Expanded(
      flex: 2,
      child: Image.network(
        "https://picsum.photos/150",
      ),
    ),
  ]
),
03_expanded.png

Stack

Stack is a powerful layout widget that positions its children relative to its edges. Also, its children can overlap. The Z-order of the children is defined by the order they appear in Stacks list of children.

Stacks children are either positioned or non-positioned, positioned meaning wrapped in a configured Positioned widget. Stack sizes itself to contain all the non-positioned children (which are positioned by alignment, which defaults to top-left in left-to-right environments and top-right in right-to-left environments), then it places the positioned children relative to the edges.

Stack(
  children: [
    Container(...),
    Positioned(
      left: 250,
      top: 230,
      child: Image.asset(
        'assets/images/logo_flutter.png',
        height: 100,
      ),
    ),
  ]
),
03_stack.png

Conclusion

In this chapter, we've learned how to start building a Flutter app without any design language support, then we discussed the built-in Material and Cupertino support. We've got to try some essential UI and layout widgets and learned some fundamental techniques for building beautiful UIs with Flutter in the future. In the next chapter, we'll learn about supporting multiple languages in our apps and building responsive UIs for different environments - mobile, web, and desktop.

References, materials, further reading