Skip to content

How to avoid modifier methods

Matt Carroll edited this page Oct 11, 2023 · 3 revisions

Swift UI includes a concept called "modifier methods". Modifier methods are used to accomplish two distinct goals in Swift UI.

  • First, they're called on a view to adjust the styles of that view, e.g., bold text, padding, frame size.
  • Second, they're used to configure ancestor views, e.g., changing the page title from deep down in the view tree.

The swift_ui package doesn't use modifier methods. Here's why

Without modifier methods, the swift_ui package needs approaches to accomplish the same goals by way of Flutter's traditional widget composition system. This post describes how to do that.

Styling, sizing, and positioning content

Wherever Swift UI styles, sizes, and positions content with modifier methods, the swift_ui package uses widget composition.

The following examples demonstrate ways in which Swift UI code might be ported to Flutter within the swift_ui package.

Example 1

Text("Turtle Rock")
  .font(.title)
  .foregroundColor(.green)
// Hypothetical widget version
ForegroundColor(
  Colors.green,
  child: Font(
    Labels.title,
    child: Text("Turtle Rock"),
  ),
);

Example 2

Ellipse()
  .fill(Color.purple)
  .frame(width: 200, height: 100)
// Hypothetical widget version
Frame(
  width: 200,
  height: 100,
  child: Fill(
    Colors.purple,
    child: Ellipse(),
  ),
);

Note: At first glance, the widget code feels a lot more verbose. This is largely the result of Flutter's style of placing one property per line, combined with the convention of giving child widgets a property named child. In practice, when working with the complexity of a real UI project, this difference in verbosity is negligible. It's only a concern in very small snippets like this.

Changing ancestor UX

One of the more unusual behaviors in Swift UI is the use of modifier methods to change the UX of ancestors. A common example the use of a modifier method deep down in a view tree to change the title in the navigation bar.

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("Hello, world!")
                    .navigationBarTitle("Navigation 1")
            }
            .padding()
        }
    }
}

Notice the .navigationBarTitle() modifier method that's called on Text. What does navigationBarTitle() have to do with Text? Well...nothing. Instead, that navigationBarTitle() modifier method changes the title for the entire NavigationView. Notice that there are multiple descendent view's: NavigationView > Padding > VStack > Text > .navigationBarTitle(). Despite the fact that navigationBarTitle() is buried deep within the tree, that method still successfully causes the ancestor NavigationView to change its title.

In practice, it's unclear if this pattern of behavior is even a good idea. Modifier methods, which alter ancestor configuration, can easily create confusion based on execution order. For example, consider the following alternative Swift UI view:

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("Hello, world!")
                    .navigationBarTitle("Navigation 1")

                Text("Hello, world 2!")
                    .navigationBarTitle("Navigation 2")
            }
            .padding()
        }
    }
}

Modifier methods make it easy to accidentally bury ancestor configuration in multiple, unrelated views, leading to unexpected and perhaps undefined behavior. Given the example above, what do you think appears as the title for the NavigationView?

One might assume that the title would be "Navigation 2", because that's the last line of code to run. However, it was found experimentally that the title is "Navigation 1". It's unclear why this is the case, how a developer would know it's the case, and why such modifier methods would be considered a healthy developer tool.

Nonetheless, an honest port of Swift UI needs to make it possible to alter something like a navigation bar from a descendent location.

Fortunately, Flutter has a battle-tested solution for such situations, and it once again leverages aggressive widget composition.

One could accomplish the aforementioned Swift UI example by implementing and composing widgets like the following:

NavigationView(
  child: Padding(
    child: VStack(
      children: [
        NavigationBarTitle(
          "Navigation 1",
          child: Text("Hello, World!"),
        ),
        NavigationBarTitle(
          "Navigation 2",
          child: Text("Hello, World 2!"),
        ),
      ],
    ),
  ),
);

With the above hypothetical widget tree, the important question is: how does NavigtionBarTitle alter the ancestor NavigationView?

The answer to this question, and all other similar questions, is some combination of accessing ancestor InheritedWidgets and ancestor State objects.

First, in the ancestor widget, create a method that makes it easy for descendants to find the ancestor. You can create methods like this that return data structures, values, or State objects.

class NavigationView extends StatefulWidget {
  static NavigationViewState of(BuildContext context) {
    return context.findAncestorStateOfType<NavigationViewState>()!;
  }

  //...
}

class NavigationViewState extends State<NavigationView> {
  void didChangeDependencies() {
    //
  }
}
Clone this wiki locally