The UI Layer: UI, Presenter and View Model

Lets discuss in more detail the components of the UI Layer

UI Layers

As mentioned on the previous topic, the UI component lives on the most external layer of the architecture. It means that it is related to specific libraries that conform the frontend of the application, in our case, the Flutter widgets libraries.

When building an app using the Clean Framework classes, we try to separate as much as possible any code that is not related to pure UI logic and put that on the Presenter (to send and receive data from internal layers) and the Use Case (the normal location for business logic).

UI is a class that behaves like a Stateless Widget. It will be very rare that a Stateful Widget is needed, since the state usage for important data breaks the layer rules. Try to always think on ways the UI widgets without the need for Stateful Widgets.

All UI implementations require at least one View Model to fetch data from the entities. This data comes from Use Case Outputs, which Presenters receive and translate as needed.

The feature you code can be expressed into multiple screens presented to the user, and even include small widgets that are inserted in other screens. These are your entry points to the feature, and as such, will require for the UI to listen to the state changes of the feature's Use Case through its Outputs. In other words, Use Cases can have multiple Outputs, that can have relationships with many View Models through the Presenters.

View Models are immutable classes, almost pure PODOs (Plain Old Dart Objects). We try to make them as lean as possible, because its only responsibility is the passing of digested data fields into the UI object.

They tend to have only Strings. This is intentional since the Presenter has the responsibility of any formatting and parsing done to the data.

Finally, the Presenters purpose is to connect and listen to Use Case Providers to interact with the Use Case instance and pass messages for user actions done on the UI (through callbacks on the View Model) and also to trigger rebuilds on the UI when the state changes causes a new Output to be generated. This will be explained in detail on the following sessions, so for now just assume the Presenters associate with only one type of Output.

The most important job of the Presenter is to translate an Output instance and create a new View Model everytime the Output is received.

Codelab#

To better understand the flow and project structure, we'll create an application that will fetch data from a service and display it on a screen.

For this example, we will use the PokéAPI.

Coding the UI Layer#

After a feature folder is created, any developer will probably try to start adding Flutter Widgets to build up the code requirements. This framework is flexible enough to allow you to start coding components that don't require to have any access or even knowledge of any possible dependency (databases, services, cache, etc), because those concerns belong to other layers.

The simplest way to start working on a new feature is to first decide how many UI elements will be required to complete the implementation of the feature.

The feature requirements#

We are going to code a very simple feature which can be explained in a few Gherkin scenarios:

Given I have navigated to the Home feature
Then I will see the list of Pokemon
And I will see a search bar
When I type a Pokemon name on the search bar
Then I will see the list of Pokemon filtered by the search term

And this is the design of the page, which we have as reference.

Pokemon App

The first step is to create a view model with the properties that the feature requires. Let's create one at lib/features/home/presentation directory:

lib/features/home/presentation/home_view_model.dart#

class HomeViewModel extends ViewModel {
  const HomeViewModel({
    required this.pokemons,
    required this.isLoading,
    required this.hasFailedLoading,
    required this.onRetry,
    required this.onRefresh,
    required this.onSearch,
  });

  final List<PokemonViewModel> pokemons;
  final bool isLoading;
  final bool hasFailedLoading;

  final VoidCallback onRetry;
  final AsyncCallback onRefresh;
  final ValueChanged<String> onSearch;

  @override
  List<Object?> get props => [pokemons, isLoading, hasFailedLoading];
}

class PokemonViewModel extends ViewModel {
  const PokemonViewModel({
    required this.name,
    required this.imageUrl,
  });

  final String name;
  final String imageUrl;

  @override
  List<Object?> get props => [name, imageUrl];
}

The next step is to create a Presenter. This class will be responsible for listening to the Use Case outputs and creating the View Model. For Presenter, we need to setup a skeleton of Use Case first.

Note: Here we'll only create the skeleton of the Use Case, but we'll not implement it yet.

Let's create it at lib/features/home/domain directory:

lib/features/home/domain/home_entity.dart#

class HomeEntity extends Entity {
  @override
  List<Object?> get props => [];
}

lib/features/home/domain/home_ui_output.dart#

class HomeUIOutput extends Output {
  @override
  List<Object?> get props => [];
}

lib/features/home/domain/home_use_case.dart#

class HomeUseCase extends UseCase<HomeEntity> {
  HomeUseCase() : super(
    entity: HomeEntity(),
    transformers: [
      OutputTransformer.from((_) => HomeUIOutput()),
    ],
  );
}

After the entity skeleton, let's create a provider for it in the lib/providers.dart:

lib/providers.dart#

final homeUseCaseProvider = UseCaseProvider(HomeUseCase.new);

Now let's create the Presenter at lib/features/home/presentation directory:

lib/features/home/presentation/home_presenter.dart#

class HomePresenter extends Presenter<HomeViewModel, HomeUIOutput, HomeUseCase> {
  HomePresenter({
    super.key,
    required super.builder,
  }) : super(provider: homeUseCaseProvider);

  @override
  HomeViewModel createViewModel(HomeUseCase useCase, HomeUIOutput output) {
    const spriteBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world';

    return HomeViewModel(
      pokemons: const [
        PokemonViewModel(name: 'Bulbasaur', imageUrl: '$spriteBaseUrl/1.svg'),
        PokemonViewModel(name: 'Charmander', imageUrl: '$spriteBaseUrl/4.svg'),
        PokemonViewModel(name: 'Squirtle', imageUrl: '$spriteBaseUrl/7.svg'),
        PokemonViewModel(name: 'Pikachu', imageUrl: '$spriteBaseUrl/25.svg'),
      ],
      onSearch: (query) {},
      onRefresh: () async {},
      onRetry: () {},
      isLoading: false,
      hasFailedLoading: false,
    );
  }
}

Then we can create the UI at lib/features/home/presentation directory:

lib/features/home/presentation/home_ui.dart#

class HomeUI extends UI<HomeViewModel> {
  HomeUI({super.key});

  @override
  HomePresenter create(PresenterBuilder<HomeViewModel> builder) {
    return HomePresenter(builder: builder);
  }

  @override
  Widget build(BuildContext context, HomeViewModel viewModel) {
    final textTheme = Theme.of(context).textTheme;

    Widget child;
    if (viewModel.isLoading) {
      child = const Center(child: CircularProgressIndicator());
    } else if (viewModel.hasFailedLoading) {
      child = _LoadingFailed(onRetry: viewModel.onRetry);
    } else {
      child = RefreshIndicator(
        onRefresh: viewModel.onRefresh,
        child: Scrollbar(
          thumbVisibility: true,
          child: ListView.builder(
            prototypeItem: const SizedBox(height: 176), // 160 + 16
            padding: const EdgeInsets.symmetric(horizontal: 16),
            itemBuilder: (context, index) {
              final pokemon = viewModel.pokemons[index];

              return PokemonCard(
                key: ValueKey(pokemon.name),
                imageUrl: pokemon.imageUrl,
                name: pokemon.name,
                heroTag: pokemon.name,
                onTap: () { /*TODO: Navigate to detail page*/ },
              );
            },
            itemCount: viewModel.pokemons.length,
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Pokémon'),
        centerTitle: false,
        titleTextStyle: textTheme.displaySmall!.copyWith(
          fontWeight: FontWeight.w300,
        ),
        bottom: viewModel.isLoading || viewModel.hasFailedLoading
            ? null
            : PokemonSearchField(onChanged: viewModel.onSearch),
      ),
      body: child,
    );
  }
}

class _LoadingFailed extends StatelessWidget {
  const _LoadingFailed({required this.onRetry});

  final VoidCallback onRetry;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children: [
          Text(
            'Oops, loading failed.',
            style: Theme.of(context).textTheme.displaySmall,
          ),
          const SizedBox(height: 24),
          OutlinedButton(
            onPressed: onRetry,
            child: const Text('Help Flareon, find her friends'),
          ),
          const SizedBox(height: 64),
        ],
      ),
    );
  }
}

Finally, set the HomeUI as the home for the app.

Note: Remember to add AppProviderScope as the top level widget, which hold all the state of providers created by the app.

lib/main.dart#

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

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

  @override
  Widget build(BuildContext context) {
    return AppProviderScope(
      child: MaterialApp(
        title: 'Pokemon',
        theme: ThemeData.from(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
          useMaterial3: true,
        ),
        home: HomeUI(),
      ),
    );
  }
}

At this point the app should run perfectly fine with the static data we added in the view model.

Hopefully by now you can appreciate the capacity of the Clean Framework components to help developers work with the UI layer without the need to first finish the Domain Layer code. You can even work in parallel with another developer that is doing it, while also having a high coverage on your code.

It has to be noted that this is very helpful to create MVP builds and have a working prototype that can be reviewed by stakeholders and QA teams, saving the development team a lot of headaches, since the feedback can be received sooner.