jeudi 23 mars 2023

How to use Shared Element Transition and State Pattern Together in Flutter?

I'm working on a Flutter widget that uses the state pattern whereby the layout changes in reaction to the state of an underlying model, so effectively there is a portion of the widget tree being swapped out when the state changes... but it does not use navigation.

But the basic (abstract) idea is:

The UI is presented to the user in a specific state.
The user completes some action available in that state by interacting with the UI.
The action triggers a function in the underlying model.
The model decides whether that was successful or not and if so, transitions to the next appropriate state.
The UI (stateful widget) is notified of this state change and replaces the section of the widget tree corresponding to the current state's layout.

The hierarchy of objects is like this:

Stateless widget (the main widget that the developer uses).

  • "has a" Stateful Widget (containing the various states that are "swapped out" as the model state changes)
    • "has a" model

The reason for using the state pattern is that in my specific case the widget is for recording, playing, and editing audio and is rather complicated as I only want the minimum necessary widgets present in any given state.

When the state changes, the various widgets' position, shape in some cases, visibility, interactivity, and click behavior all change.

Also there are states than can be transitioned to from more than one other state.

So you can see how this would be hell to implement in a single stateful widget and why I chose the state pattern to help with a separation of concerns.

Incidentally, I have previously implemented it the hellish way (no code provided here), just not with any animation:

enter image description here

Since then I have refactored it using the underlying model and state pattern in the hopes that adding animation would be the next step.

So far, I've gotten everything working (it doesn't crash and this is not a debugging question) except there is no animation between the states as is.

This is some example code that shows my current approach:

import 'package:flutter/material.dart';
import 'model.dart';

class AudioRecorderUI extends StatefulWidget {
  final AudioRecorderModel model;

  const AudioRecorderUI({super.key, required this.model});

  @override
  State<AudioRecorderUI> createState() => _AudioRecorderUIState();
}

class _AudioRecorderUIState extends State<AudioRecorderUI> {
  // the audio interface widget that corresponds to the current state of the model
  late AudioUIState _currentUIState;

  @override
  void initState() {
    super.initState();
    // add listener to the model
    widget.model.addListener(_reactToModelState);

    // set initial state
    // TODO: this is potentially problematic (calling setState inside initState)
    _reactToModelState();
  }

  @override
  void dispose() {
    // Remove the listener when the widget is disposed
    widget.model.removeListener(_reactToModelState);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _currentUIState.build(context);
  }

  // ---------------------------------------------------------------------------------- REACTING TO MODEL'S STATE

  // Update the UI state based on the model's new state.
  // This is run manually the first time in order to set the initial state (which dpeneds on whether a recording exists or not)
  // From then onwards, it is set automatically using ChangeNotifier
  void _reactToModelState() {
    setState(() {
      _currentUIState = _getUIStateFromModelState(widget.model.state);
    });
  }

  AudioUIState _getUIStateFromModelState(AudioWidgetState modelState) {
    switch (modelState) {
      case AudioWidgetState.standby:
        return StandbyUIState(model: widget.model);
      case AudioWidgetState.recording:
        return RecordingUIState(model: widget.model);
      case AudioWidgetState.post:
        return PostUIState(model: widget.model);
    }
  }
}

// ------------------------------------------------------------------------------------------ UI STATES

// Define the different UI states as separate classes by extending an abstract class

abstract class AudioUIState {
  final AudioRecorderModel model;
  AudioUIState({required this.model});

  // this is a multiplier we use to establish a reference unit
  // ... by multiplying it with the view height.
  // We use the result for:
  // - the height and width of buttons
  // - add more
  static const double multiplierForGridSize = 0.125; 

  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return generateStackViewInConcreteInstance(
            height: constraints.maxHeight, width: constraints.maxWidth, gridSize: constraints.maxHeight * multiplierForGridSize);
      },
    );
  }

  // this is an abstract method
  Stack generateStackViewInConcreteInstance(
      {required double height, required double width, required double gridSize});
}

class StandbyUIState extends AudioUIState {
  StandbyUIState({required super.model});

  @override
  Stack generateStackViewInConcreteInstance(
      {required double height, required double width, required double gridSize}) {
    return Stack(
      children: [
        Positioned(
          top: (height/2)-(gridSize/2),
          left: (width/2)-(gridSize/2),
          child: Container(color: Colors.lightBlue, height: gridSize, width: gridSize,
            child: TextButton(
              onPressed: () {
                model.startRecording();
              },
              child: const Text("REC"),
            ),
          ),
        ),
      ],
    );
  }
}

class RecordingUIState extends AudioUIState {
  RecordingUIState({required super.model});

  @override
  Stack generateStackViewInConcreteInstance(
      {required double height, required double width, required double gridSize}) {
    return Stack(
      children: [
        Positioned(
          top: 20,
          left: 20,
          child: Hero(tag: "poo",
            child: Container(color: Colors.red, height: gridSize, width: gridSize,
              child: TextButton(
                onPressed: () {
                  model.stopRecording();
                },
                child: const Text("STOP"),
              ),
            ),
          ),
        ),
        Positioned(top: height/6, left: 0, child: Container(height: 2*height/3, width: width, color: Colors.yellow),)
      ],
    );
  }
}

class PostUIState extends AudioUIState {
  PostUIState({required super.model});

  @override
  Stack generateStackViewInConcreteInstance(
      {required double height, required double width, required double gridSize}) {
    return Stack(
      children: [
        Positioned(
          top: 20,
          left: 20,
          child: Container(color: Colors.red, height: gridSize, width: gridSize,
            child: TextButton(
              onPressed: () {
                model.startRecording();
              },
              child: const Text("RE-REC"),
            ),
          ),
        ),
        Positioned(
          top: 20,
          left: width/2 - gridSize/2,
          child: Container(color: Colors.green, height: gridSize, width: gridSize,
            child: TextButton(
              onPressed: () {
                model.startPlaying(from: 0, to: 1);
              },
              child: const Text("PLAY"),
            ),
          ),
        ),
        Positioned(
          top: 20,
          right: 20,
          child: Container(color: Colors.green, height: gridSize, width: gridSize,
            child: TextButton(
              onPressed: () {
                model.stopPlaying();
              },
              child: const Text("STOP"),
            ),
          ),
        ),
        Positioned(top: height/6, left: 0, child: Container(height: 2*height/3, width: width, color: Colors.yellow),)
      ],
    );
  }
}

As you can see in the example code, the states are separate classes that extend a base class.

As stated, I would like to have the various views and buttons "shared" by these states (they are not actually shared, unfortunately) animate their properties between states.

But with the current design, I can't just use an Animated widget like AnimatedPositioned because that widget is designed to be used within a single stateful widget's state class and animate as its properties are updated and setState is called.

However, in my case, the entire class containing said AnimatedPositioned widget would be replaced. And so an AnimatedPositioned widget would only work if I abandoned the state pattern and combined all the states into a single stateful widget.

So it seems what I'm seeking is a "shared element transition."

However, the only existing options for this seem to be a Hero widget or a PageRouteTransition, neither of which work in this scenario because I'm not using navigation.

You may not think that the state pattern is necessary because the functionality seems simple, but this is a minimal example and it's going to get much more complicated.

I am at the very limit of my abilities here!

How can the animations be achieved? That is, is there any "common practice" that isn't messy or "work-aroundy" that accomplishes what I'm trying do within the confines of the current design... or would I need to change the whole approach?

Ideally, the code should be structured in a such a way that each state's logic is separated, and that a developer could easily add an entire new state or change the layout of an existing state, and yet the states would still animate between one another without that having to be defined explicitly.

Things I have considered:

  • put everything in one huge class and just do all the animations explicity (Noooo!)
  • Use an animated builder to wrap each state and go from there somehow.
  • Use a Flow widget as suggested in comments (It feels to difficult to figure out how to apply that to this use case)
  • Add a new layer like an "Animation Coordinator" between the model and the main layout container that is aware of all the target layout configurations of each state, and what state is being transitioned from and to.

Aucun commentaire:

Enregistrer un commentaire