Mastering TextStyle - Part 1

Mastering TextStyle - Part 1

How Flutter's Widget Tree Provides the Style to Text

Let's say placing Text with its ancestor MaterialApp and Scaffold, you will see the black-colored text on your screen.

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Text('Hi, it\'s Chooyan Here'),
      ),
    ),
  )
}

Now, can you imagine what will happen if we omit Scaffold or MaterialApp from the code above? Let me test those cases.

When I first remove Scaffold, but MaterialApp is still there, red-colored yellow-underlined text appeared.

If I remove MaterialApp but Scaffold is there, on the other hand, black-colored text appeared again.

Curiously, the appearance of Text changes even though we don't give any TextStyle through style argument. It changes depending on what widgets are on its ancestor in reality.

That is the power of Flutter's widget tree, and we will discuss how Text finds TextField from the widget tree in this article, and also discuss how we can maximize the potential of this mechanism in our app in the next article.


Text and Its Implementation

Let's get started by taking a closer look at Text.

Text is a subclass of StatelessWidget, and it composes what to display on users' screens in the build() method as we do in our StatelessWidgets.

Thus, if we want to know how Text decides what style it applies in reality, we can jump into the build() method and read the implementation.

Once we head to text.dart in the Flutter framework, we soon find build() method starting with the code below.

class Text extends StatelessWidget {
  ...

  @override
  Widget build(BuildContext context) {
    final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
    TextStyle? effectiveTextStyle = style;
    if (style == null || style!.inherit) {
      effectiveTextStyle = defaultTextStyle.style.merge(style);
    }
    ...
  }
}

As we can see, Text tries to find DefaultTextStyle by calling DefaultTextStyle.of(context).

DefaultTextStyle.of() is a method that finds DefaultTextWidget from context's ancestor on the widget tree. If no DefaultTextStyle is found, empty TextStyle will be returned as DefaultTextStyle.fallback().

class DefaultTextStyle extends InheritedTheme {
  static DefaultTextStyle of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<DefaultTextStyle>() ?? const DefaultTextStyle.fallback();
  }
}

In other words, if we want to find out how Text decides its style without style argument given, what we need to do is find out widgets that place DefaultTextStyle in their build() method.

The Strategy of MaterialApp and Scaffold

So, let's step into the discussion of how MaterialApp and Scaffold places DefaultTextStyle in their build() method.

MaterialApp

Because MaterialApp is a subclass of StatefulWidget, it's build() method is defined in its associated State class, named _MaterialAppState.

Reading the build() method and related internal methods, the code below can be found.

class _MaterialAppState extends State<MaterialApp> {
  ...
  Widget _buildWidgetApp(BuildContext context) {
    return WidgetsApp.router(
      ...
      textStyle: _errorTextStyle,
    );
  }
}
const TextStyle _errorTextStyle = TextStyle(
  color: Color(0xD0FF0000),
  fontFamily: 'monospace',
  fontSize: 48.0,
  fontWeight: FontWeight.w900,
  decoration: TextDecoration.underline,
  decorationColor: Color(0xFFFFFF00),
  decorationStyle: TextDecorationStyle.double,
  debugLabel: 'fallback style; consider putting your text in a Material',
);

The _errorTextStyle is the exact one that is referred to display the red-colored yellow-underlined text appearing when Scaffold is omitted.

The textStyle passed to WidgetsApp is used to build DefaultTextStyle in _WidgetsAppState with the code below.

class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
  ...
  @override
  Widget build(BuildContext context) {
    if (widget.textStyle != null) {
      result = DefaultTextStyle(
        style: widget.textStyle!,
        child: result,
      );
    }
  }
}

Briefly summarising the widget tree constructed by MaterialApp, we can illustrate the situation below.

Scaffold

Next, we will focus on Scaffold and how it provides DefaultTextStyle to its descendant Texts.

Scaffold is a subclass of StatefulWidget, so we can jump to ScaffoldState and read build() method. However, when we try to find DefaultTextStyle in the scaffold.dart file, we soon find that zero result is hit, in other words, Scaffold or ScaffoldState don't directly place DefaultTextStyle in their build() method. So who does?

Instead of DefaultTextStyle, ScaffoldState places Material on the widget tree.

class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin, RestorationMixin {
  ...
  @override
  Widget build(BuildContext context) {
    ...
        child: Material(
          color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor,
          ...
        ),
  }
}

Material is also a StatefulWidget and _MaterialState has an implementation that we now want to find out.

class _MaterialState extends State<Material> with TickerProviderStateMixin {
  ...
  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    ...
    Widget? contents = widget.child;
    if (contents != null) {
      contents = AnimatedDefaultTextStyle(
        style: widget.textStyle ?? Theme.of(context).textTheme.bodyMedium!,
        duration: widget.animationDuration,
        child: contents,
      );
    }
  }
}

_MaterialState first find ThemeData from the widget tree by calling Theme.of(context), and then create AnimatedDefaultTextStyle, which is an "Animated" version of DefaultTextStyle, and passing Theme.of(context).textTheme.bodyMedium to style argument (widget.textStyle is null in this case).

Looking back on the usage of MaterialApp, ThemeData is an object that we can pass to MaterialApp through theme argument.

In short, Scaffold internally places DefaultTextStyle using textTheme.bodyMedium exposed by ThemeData provided by MaterialApp on the widget tree.

Check the situation so far with the image below.

We can see that error-styled DefaultTextStyle is still on the widget tree but is not referred to by Texts below Scaffold anymore because Scaffold provides another DefaultTextStyle. That is why we never see error-styled texts in our app as long as we use Scaffold at every page.


Conclusion

In this article so far, we have discussed how Text finds DefaultTextStyle from the widget tree, and how MaterialApp and Scaffold place DefaultTextStyle on the widget tree with reading internal source codes.

What is interesting here is that DefaultTextStyle can be overridden by a descendant. Text never use MaterialApp's DefaultTextStyle, which is an error-styled one, but uses its descendant Scaffold's because DefaultTextStyle.of(context) returns the closest ancestor DefaultTextStyle from the widget tree.

In other words, if we want to make it clear what style would be applied to each Text, what we should do is to explorer DefaultTextStyle provided by the closest ancestor.

This mechanism is nicely used in various kinds of widgets, such as AppBar, Dialog, etc, but we also encounter troubles related to DefaultTextStyle, when using Overlay for example.

We will discuss them in detail in the next article. Thank you for reading.