Skip to main content

Command Palette

Search for a command to run...

All I Know About WidgetsApp

An entry point for independence from platform-specific design languages

Updated
13 min read
All I Know About WidgetsApp
T

I am a freelance mobile app developer, mainly working on Flutter. I'm also developing a Flutter package named "crop_your_image", which enables Flutter app developers to build cropping images functionality in their app with their own designed UI. https://pub.dev/packages/crop_your_image

According to the FAQ page of the Flutter doc, the key concept of Flutter in the design field is to make brand-driven designed apps without dependency on platform-specific design languages, such as material design, liquid glass, etc.

https://docs.flutter.dev/resources/faq#why-would-i-want-to-share-layout-code-across-ios-and-android

In order to maximize the benefit of this idea and avoid constraints of platform-specific design, we have an uncommon option, “using WidgetsApp, not MaterialApp".

For instance, ElevatedButton causes a splash effect by default when tapped, AppBar has its fixed height of 56.0 which is defined as kToolbarHeight, or even a navigation animation caused by MaterialPageRoute mimics platform-specific ones.

I know they are so useful when making Flutter apps, yet, they sometimes prevent us from achieving 100% bespoke designs as long as those material widgets have their hard-coded layout values or rules.

That’s why using WidgetsApp instead of MaterialApp is a good choice to be free from those limitations and build well-designed apps, I would say.

But once we decide to use WidgetsApp, throwing MaterialApp or other material.dart widgets away, we soon find we have several pitfalls that are usually addressed by material widgets.

Thus, in this article, I’ll investigate how we can build a simple app that consists of UserListPage and UserDetailPage without importing material.dart, discussing what problems cause and how we can solve them.

What are material widgets?

Before coding the app with WidgetsApp, let’s first take an overview of the widgets exposed by material.dart to make it clear what widgets can’t be used in this article.

MaterialApp is the most typical material widget that preserves the entire configuration to build our app based on the material design. Some of you may know that MaterialApp places WidgetsApp inside its build() passing the appropriate configurations and dispatching the given arguments from us.

Scaffold is also a frequently used widget that provides the typical layout of one page, which consists of AppBar, FloatingActionButton, BottomNavigationBar, etc. This also places Material inside its build() to provide DefaultTextStyle to descendants and preparing InkFeatures to display a splash effect caused by user interactions.

ElevatedButton, AppBar, FloatingActionButton, ListTile, Card, CircularProgressIndicator, and more are all based on material design that depend on Theme provided by MaterialApp.

Icons and Colors are also classes that provide pre-defined IconData and Color values based on material design. Note that Icon itself is exposed by widgets.dart, not material.dart. We can define IconData without depending on material design and, of course, so as Color.

In short, using WidgetsApp means we can’t use those useful widgets anymore. We may think it’s inconvenient and an unnecessary limitation, but I say the limitation gives us freedom to render whatever design defined by the designer of each project under 100% control.

Start with WidgetsApp

Now, let’s get started by placing WidgetsApp at the top of our widget tree.

import 'package:flutter/widgets.dart';

void main() => runApp(const MainApp());

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) =>
      WidgetsApp(color: const Color(0xFF1E1E1E), home: Container());
}

WidgetsApp requires one argument, color, which is used Title widget that “describes this app to the Android operating system”. In other words, the color specified here isn’t used in the widget tree or doesn’t affect the appearances of other widgets at all.

home is also required in the same manner as MaterialApp, so we just place an empty Container as a placeholder now.

When we run the app with the source code above, the error below will be shown.

════════ Exception caught by widgets library ═══════════════════════════════════
The following assertion was thrown building MainApp(dirty):
If neither builder nor onGenerateRoute are provided, the pageRouteBuilder must be specified so that the default handler will know what kind of PageRoute transition to build.
'package:flutter/src/widgets/app.dart':
Failed assertion: line 406 pos 10: 'builder != null || onGenerateRoute != null || pageRouteBuilder != null'

This says we have to specify the configuration about routings.

Though we have multiple options to solve it, we’ll pick the solution of adding pageRouteBuilder argument, as MaterialApp also does inside its build() method.

  return WidgetsApp(
    ...省略

    pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) {
      return MaterialPageRoute<T>(settings: settings, builder: builder);
    },
    home: widget.home,

    ...省略
  );

https://github.com/flutter/flutter/blob/42553c9f6956afdc951174f2ee7b932d7e330024/packages/flutter/lib/src/material/app.dart#L1106

This pageRouteBuilder function will be called when Navigator tries to navigate to named routes, including the initial route / when launching the app.

Though MaterialApp specifies using MaterialPageRoute, we’ll try to customize the routing animation in this app to be fully independent from material.dart or cupertino.dart.

Customize route animations

Fortunately, the document below shows us concise information on customizing routing animations.

https://docs.flutter.dev/cookbook/animation/page-route-animation

According to the document, the straightforward way to customize the route transition animation is to use PageRouteBuilder, providing functions of pageBuilder and transitionsBuilder.

What we implement here is a logic to provide a widget to be shown as the next page in the pageBuilder function, and a widget to animate the page widget in the transitionsBuilder.

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return WidgetsApp(
      color: const Color(0xFF1E1E1E), 
      home: Container(),
      pageRouteBuilder: <T>(settings, builder) => PageRouteBuilder(
        pageBuilder: (context, _, _) => builder(context),
        transitionsBuilder: (_, animation, _, child) => 
          FadeTransition(opacity: animation, child: child),
      ),
    );
  }
}

This simple example uses FadeTransition for animation, which receives Animation to perform animation inside. As transitionsBuilder provides us animation which preserves the animation state of page transition, we can just pass it to FadeTransition to achieve a “fading” route animations.

The document also introduces customizing the animation object by combining Tween or other animation-related objects, such as CurveAnimation, for more complex route animations.

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  const begin = Offset(0.0, 1.0);
  const end = Offset.zero;
  const curve = Curves.ease;

  final tween = Tween(begin: begin, end: end);
  final curvedAnimation = CurvedAnimation(parent: animation, curve: curve);

  return SlideTransition(
    position: tween.animate(curvedAnimation),
    child: child,
  );
}

https://docs.flutter.dev/cookbook/animation/page-route-animation#5-combine-the-two-tweens

See the section linked above if you are interested in more complicated route animations.

Now, our app can be launched without any errors: this only shows the entirely black screen, though.

Build a list page

Now, we are ready to build the UI of our app.

The first page I will show in this article is UserListPage, which demonstrates a typical “list page” without using any material-related widgets, such as Scaffold, AppBar, ListTile, etc.

So what we use here are:

  • ColoredBox to specify the background color

  • ListView to display the list (as usual)

  • Container, Column, Text, Padding, or other basic widgets to make customized _ListTile

So, the source code of our UserListPage looks like below.


const users = [
  // dummy user data
];

/// a page widget representing the list
class UserListPage extends StatelessWidget {
  const UserListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return ColoredBox( // for the background color
      color: const Color(0xFF1E1E1E),
      child: ListView.builder( // for displaying the list
        itemCount: users.length,
        itemBuilder: (context, index) {
          return _ListTile(user: users[index]);
        },
      ),
    );
  }
}

/// customized list tile widget using only basic widgets
class _ListTile extends StatelessWidget {
  const _ListTile({required this.user});

  final User user;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: const BoxDecoration(
        border: Border(bottom: BorderSide(color: Color(0xFF333333), width: 1)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // displaying user's name and email address
        ],
      ),
    );
  }
}

And this displays the UI below.

As we see, it’s proven that importing material.dart is not always required to build our UI, and WidgetsApp and basic widgets exposed by widgets.dart are capable of building whatever brand-driven design.

Navigate to a detail page

What we build next is a typical “detail page” that also consists only of basic widgets.

Though building a “detail page” is simple, we have to take a couple of more steps to let users navigate to the page, which are:

  • adding a tap feature with GestureDetector

  • implementing a “tap effect” for feedback to the user interaction

  • calling Navigator.push() with the customized PageRouteBuilder

So, let’s take a closer look at each step.

Add a tap feature with GestureDetector

Because our _ListTile doesn’t have onTap argument, unlike the material ListTile widget, we have to manually add the functionality using GestureDetector like below.

/// customized list tile widget using only basic widgets
class _ListTile extends StatelessWidget {
  const _ListTile({required this.user, this.onTap});

  final User user;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector( // interacting with user's gesture
      onTap: onTap,
      child: Container(
        // no change
      ),
    );
  }
}

Implement a “tap effect” for feedback to the user interaction

Though GestureDetector enables the app to interact with the user’s gestures, nothing changes on its UI even when the user taps the tile because the feedback is out of its responsibility, so we have to also implement the “feedback” by ourselves using StatefulWidget.

/// customized list tile widget using only basic widgets
class _ListTile extends StatefulWidget {
  const _ListTile({required this.user, this.onTap});

  final User user;
  final VoidCallback? onTap;

  @override
  State<_ListTile> createState() => _ListTileState(); 
}

class _ListTileState extends State<_ListTile> {

  bool _isPressing = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector( // interacting with user's gesture
      onTapDown: (_) => setState(() => _isPressing = true),
      onTapUp: (_) => setState(() => _isPressing = false),
      onTapCancel: () => setState(() => _isPressing = false),
      onTap: widget.onTap,
      child: Container(
        color: isPressed ? const Color(0xFF111111) : null,
        child: AnimatedScale(
          alignment: Alignment.centerLeft,
          scale: isPressed ? 0.90 : 1,
          duration: const Duration(milliseconds: 100),
          curve: Curves.fastEaseInToSlowEaseOut,
          child: Column(
            // existing tile UI
          ),
        ),
      ),
    );
  }
}

This simply toggles the state of _isPressing when onTapDown and onTapUp, as well as onTapCancel called when user stop touching the screen outside of _ListTile.

Because the presentation of the tile is scaled by AnimatedScale depending on the value of _isPressing, this concisely expresses users that “the tile is pressed”.

Note that since I’m not a designer, the feedback above may not look nice. Yet, with the approach introduced above, you can improve and customize this by applying a more sophisticated design that designers create.

Call Navigator.push() with the customized PageRouteBuilder

Now, let’s navigate to a detail page, named UserDetailPage, using Navigator.push() like we do with MaterialApp.

Again, defining route animations is not Navigator's business but PageRouteBuilder's.

So we can simply reuse the PageRouteBuilder which we provided to WidgetsApp in the previous step.

onTap: () => Navigator.push(
  context, 
  PageRouteBuilder(
    pageBuilder: (_, _, _) => UserDetailPage(user: user),
    transitionsBuilder: (_, animation, _, child) => 
      FadeTransition(opacity: animation, child: child),
  ),
),

While the initial route doesn’t show any route animation when launching the app and displaying UserListPage, it now navigates to UserDetailPage with a fade animation, as you can see below.

UserDetalPage has nothing but an ordinal UI implementation using Column, Text, Container, etc. On thing we have to make sure is to provide any UI that enable users to navigate back to the previous UserListPage by calling Navigaor.pop(context). Otherwise, users never come back to UserListPage because the platform-specific guestures, such as swipe from the left edge in iPhone, are not enabled by default unless using MaterialPageRoute: pressing back key on Android works without any configuration, though.

class UserDetailPage extends StatelessWidget {
  const UserDetailPage({super.key, required this.user});

  final User user;

  @override
  Widget build(BuildContext context) {
    // navigate back when tapping anywhare in the page
    return GestureDetector(
      onTap: () => Navigator.of(context).pop(), 
      child: ColoredBox(
        color: Color(0xFF1E1E1E),
        child: // UI of the detail page

Configure common theme

Though Theme preserves a common configuration about the appearance of the app, the widget is for material design widgets. So we have to construct a similar mechanism using InheritedWidget if, let’s say, we want to share Color(0xFF1E1E1E) among UserDetailPage, UserListPage, or other pages added in the future.

InheritedWidget is a fundamental widget in the Flutter framework that provides global values to its descendants with a mechanism that the descendants can access the InheritedWidget by O(1). Thanks to the feature, it can be used to share common configuration, such as background colors, text styles, device states, etc, among the entire widget tree.

If you want to know more about InheritedWidget and its concrete usage, DefaultTextStyle, head to another article “All I Know about BuildContext” linked below.

https://hashnode.com/edit/cm56iswji001j09ky79bw7bw4

Based on the knowledge about InheritedWidget, let’s implement PageTheme widget that provides PageThemeData to its ancestors.

First, PageThemeData looks like below. It’s a simple Dart class preserving a field of backgroundColor.

class PageThemeData {
  const PageThemeData({required this.backgroundColor});
  final Color backgroundColor;
}

Next, we’ll define PageTheme that extends InheritedWidget. PageTheme preserves PageThemeData and passes child and key to its superclass in the constructor.

class PageTheme extends InheritedWidget {
  const PageTheme({super.key, required super.child, required this.data});

  final PageThemeData data;
}

We also need to implement updateShouldNotify() to tell when the Flutter framework should perform rebuilds of its dependencies, and .of() to provide an accessor for preserved PageThemeData from descendants.

class PageTheme extends InheritedWidget {
  const PageTheme({super.key, required super.child, required this.data});

  final PageThemeData data;

  /// if [data] is updated, dependents should be rebuilt
  @override
  bool updateShouldNotify(PageTheme oldWidget) => data != oldWidget.data;

  /// descendants can access [PageThemeData] using PageTheme.of(context)
  static PageThemeData of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<PageTheme>()!.data;
}

That’s it!

Now, we can simply place the PageThem at the top of the widget tree like below.

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    /// place PageTheme at the top
    return PageTheme(
      data: const PageThemeData(backgroundColor: Color(0xFF1E1E1E)),
      child: WidgetsApp(

This enables UserListPage and UserDetailPage to receive the common configuration of their background color instead of directly specifying Color(0xFF1E1E1E).

class UserListPage extends StatelessWidget {
  const UserListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      // apply common configuration exposed by PageTheme
      color: PageTheme.of(context).backgroundColor,
      child: ListView.builder(

Based on the approach so far, we can add any configuration depending on the designers’ needs without worrying about the rules of material design or other design languages.

Semantics

When discussing “UI”, we must not forget the topic of accessibility.

Though “accessibility” consists of various perspectives, we here have to know Semantics that provides meanings of each widget in order for the feature of voice over, for instance, to speak relevantly.

For example, ElevatedButton (internally used ButtonStyleButton actually) provides Semantics inside its build() operation like below.

Semantics(
  container: true,
  button: widget.isSemanticButton, // always true as long as using ElevatedButton
  enabled: widget.enabled,
    child: ...

https://github.com/flutter/flutter/blob/05add65f630621447e960b72cd3f791a039b2bb4/packages/flutter/lib/src/material/button_style_button.dart#L594

Because discussion on Semantics requires an appropriate understanding of how accessibility features work, and I don’t know much about this field unfortunately, I’ll just update some of the widgets so far by mimicking the existing material widget by taking a look at their source code.

PageRouteBuilder

PageRoute also requires Semantics as MaterialPageRoute does.

  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    final Widget result = buildContent(context);
    return Semantics(scopesRoute: true, explicitChildNodes: true, child: result);
  }

https://github.com/flutter/flutter/blob/05add65f630621447e960b72cd3f791a039b2bb4/packages/flutter/lib/src/material/page.dart#L194

scopesRoute: true and explicitChildNodes: true enable Semantics to be a root of a “semantic tree” constructed by its subtree with its route name.

Similarly, our PageRouteBuilder can be wrapped with Semantics like below.

  Navigator.of(context).push(
    PageRouteBuilder(
      pageBuilder:
          (context, animation, secondaryAnimation) =>
              Semantics(
                scopesRoute: true,
                explicitChildNodes: true,
                child: UserDetailPage(user: user),
              ), 
      transitionsBuilder:
          (context, animation, secondaryAnimation, child) =>
              FadeTransition(opacity: animation, child: child),
    ),
  );

Also, our _ListTile needs Semantics as GestureDetector doesn’t provide any semantics unless we explicitly wrap.

As the role of GestureDetector in the _ListTile is a replacement of a button, the configuration of Semantics should be mimicked from ElevatedButton, and the result is like below.

return Semantics(
  button: true,
  label: '${widget.user.name}\'s tile',
  child: GestureDetector(
    onTapDown: (details) => setState(() => isPressed = true),

Again, because I’m not an expert of accessibility issues, these usages above may not be perfect or even appropreate. I’d like to discuss here is that we can’t forget the existence of accessibility issues and using Semanticss is required if we want to make our fully customized widget components.

Wrap up

So far, we’ve seen the first step of how to build Flutter apps without material.dart or cupertino.dart but with widgets.dart.

Though this looks quite inconvenient and unefficient, this strategy also gives us a complete freedom to customize UI to fully realize designer’s idea.

Independency from platform-specific design language is one of the biggest advantage of Flutter, which other multi-platform frameworks doesn’t take.

Considering we have a lot more similar approaches, such as React Native, Compose Multiplatform, or even Swift for Android platform announced a couple of days ago, understanding the advantage of Flutter and maximizing it is coming more and more important I would say.

O
oleg fare7d ago

cool ! flutter did start a material/cupertino decoupling process so hopefully we will not have to do all that soon