Flutter (71): Combination example TurnBox

Time: Column:Mobile & Frontend views:202

Implementing a TurnBox Component in Flutter

We have previously introduced the RotatedBox, which can rotate its child components, but it has two drawbacks: first, it can only rotate its child nodes in multiples of 90 degrees; second, when the rotation angle changes, the update process does not animate.

In this section, we will implement a TurnBox component that can rotate its child nodes at any angle and execute an animation to transition to the new state when the angle changes. Additionally, we can manually specify the animation speed.

The complete code for TurnBox is as follows:

import 'package:flutter/widgets.dart';

class TurnBox extends StatefulWidget {
  const TurnBox({
    Key? key,
    this.turns = .0, // Number of turns, one full turn is 360 degrees; for example, 0.25 turns is 90 degrees
    this.speed = 200, // Total duration for the transition animation
    this.child,
  }) : super(key: key);

  final double turns;
  final int speed;
  final Widget? child;

  @override
  _TurnBoxState createState() => _TurnBoxState();
}

class _TurnBoxState extends State<TurnBox> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      lowerBound: -double.infinity,
      upperBound: double.infinity,
    );
    _controller.value = widget.turns;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RotationTransition(
      turns: _controller,
      child: widget.child,
    );
  }

  @override
  void didUpdateWidget(TurnBox oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Execute transition animation when the rotation angle changes
    if (oldWidget.turns != widget.turns) {
      _controller.animateTo(
        widget.turns,
        duration: Duration(milliseconds: widget.speed),
        curve: Curves.easeOut,
      );
    }
  }
}

In the code above:

  • We achieve the rotation effect by combining RotationTransition and child.

  • In didUpdateWidget, we check whether the rotation angle has changed; if it has, we execute a transition animation.

Now let’s test the functionality of TurnBox with the following test code:

import 'package:flutter/material.dart';
import '../widgets/index.dart';

class TurnBoxRoute extends StatefulWidget {
  const TurnBoxRoute({Key? key}) : super(key: key);

  @override
  _TurnBoxRouteState createState() => _TurnBoxRouteState();
}

class _TurnBoxRouteState extends State<TurnBoxRoute> {
  double _turns = .0;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          TurnBox(
            turns: _turns,
            speed: 500,
            child: const Icon(
              Icons.refresh,
              size: 50,
            ),
          ),
          TurnBox(
            turns: _turns,
            speed: 1000,
            child: const Icon(
              Icons.refresh,
              size: 150.0,
            ),
          ),
          ElevatedButton(
            child: const Text("Rotate Clockwise 1/5 Turn"),
            onPressed: () {
              setState(() {
                _turns += .2;
              });
            },
          ),
          ElevatedButton(
            child: const Text("Rotate Counterclockwise 1/5 Turn"),
            onPressed: () {
              setState(() {
                _turns -= .2;
              });
            },
          )
        ],
      ),
    );
  }
}

When we click the rotate buttons, both icons will rotate 1/5 turn, but at different speeds. Readers can run the example themselves to see the effect.

In fact, this example only combines one component, RotationTransition, which is a very simple composite component example. Additionally, if we encapsulate a StatefulWidget, we must pay attention to whether state needs to be synchronized during component updates. For example, if we want to encapsulate a rich text display component MyRichText that can automatically handle URL links, it can be defined as follows:

class MyRichText extends StatefulWidget {
  MyRichText({
    Key? key,
    this.text, // Text string
    this.linkStyle, // URL link style
  }) : super(key: key);

  final String? text;
  final TextStyle? linkStyle;

  @override
  _MyRichTextState createState() => _MyRichTextState();
}

Next, in _MyRichTextState, we need to implement two functionalities:

  1. Parse the text string “text” and generate a cached TextSpan.

  2. Return the final rich text style in build.

The implementation of _MyRichTextState might look like this:

class _MyRichTextState extends State<MyRichText> {
  late TextSpan _textSpan;

  @override
  Widget build(BuildContext context) {
    return RichText(
      text: _textSpan,
    );
  }

  TextSpan parseText(String text) {
    // Time-consuming operation: parse the text string and construct a TextSpan.
    // Implementation omitted.
  }

  @override
  void initState() {
    super.initState();
    _textSpan = parseText(widget.text);
  }
}

Since parsing the text string and constructing a TextSpan is a time-consuming operation, we cache the parsed result in initState to avoid parsing it every time in build, and directly use the parsed result _textSpan. While this approach seems good, there is a serious problem: when the text passed in by the parent component changes (while the component tree structure remains the same), the content displayed by MyRichText will not update. The reason is that initState is only called when the State is created, so when the text changes, parseText is not executed again, resulting in _textSpan retaining the old parsed value.

To solve this problem, we can simply add a didUpdateWidget callback and call parseText again inside it:

@override
void didUpdateWidget(MyRichText oldWidget) {
  if (widget.text != oldWidget.text) {
    _textSpan = parseText(widget.text);
  }
  super.didUpdateWidget(oldWidget);
}

Some readers may think this point is simple, and indeed it is, but it is crucial. The reason for emphasizing this is that this detail can easily be overlooked during actual development. Although it is simple, it is very important. In summary, when we cache certain data that depends on widget parameters in a State, we must pay attention to whether we need to synchronize the state when the component updates.