skip to Main Content

I tried to create a multiple-option switcher in Flutter.

It looks like this:

enter image description here

enter image description here

When I created the selection indicator (the orange rectangles in the picture), I considered that I needed to add a transformation animation to the selection indicator (move to another option after selecting another option), so I created a Stack and added ButtonsRow and the selection indicator to the children, placing the selection indicator at the bottom, as shown in the picture.

But I soon found that I needed to set the width of the selection indicator according to the width of the Button, because the text length of different languages is different, and using a fixed width may cause overflow errors.

I googled information about getting the width of a Widget and found this post. I used the method in the top comment, replacing Stack with Overlay, and then using the feature of Overlay, rendering ButtonsRow first, and inserting the selection indicator after waiting for it to render.

After replacing Stack, it looked perfect (as shown in the picture), but when I clicked on another button, I found that there was no translation animation, but it jumped directly to another button.

Actual effect demonstration:

enter image description here

Code (simplified version)

class AttributeSwitcher extends StatefulWidget {
  AttributeSwitcher(
      {super.key, required this.selected, required this.onSelected}) {
    if (selected < 0 || selected > 2) {
      throw Exception('selected must be in [0, 2]');
    }
  }

  final int selected;
  final void Function(int) onSelected;

  @override
  State<AttributeSwitcher> createState() => _AttributeSwitcherState();
}

class _AttributeSwitcherState extends State<AttributeSwitcher> {

  int _selected = 0;

  @override
  void initState() {
    super.initState();
    _selected = widget.selected;
  }

  void _updateSelected(int selected) {
    setState(() {
      _selected = selected;
    });
    widget.onSelected(selected);
  }

  Widget _createSticky({
    required double posX,
    required double posY,
    required double height,
    required double width,
    required ColorScheme colorScheme,
  }) {
    return AnimatedPositioned(
        duration: const Duration(milliseconds: 500),
        top: posY,
        left: posX,
        child: UnconstrainedBox(
          child: AnimatedContainer(
            height: height,
            width: width,
            decoration: BoxDecoration(
              color: colorScheme.primaryContainer,
              borderRadius: BorderRadius.circular(80),
            ),
            duration: const Duration(milliseconds: 500),
          ),
        ));
  }

  OverlayEntry _createRow({required List<GlobalKey> btnKeys}) {
    return OverlayEntry(
      builder: (context) => Row(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          AttributeBtn(
              btnKey: btnKeys[0], text: '专注', onPressed: () => _updateSelected(0)),
          const AttributeSplitter(),
          AttributeBtn(
            btnKey: btnKeys[1],
            text: '小休息',
            onPressed: () => _updateSelected(1),
          ),
          const AttributeSplitter(),
          AttributeBtn(
            btnKey: btnKeys[2],
            text: '大休息',
            onPressed: () => _updateSelected(2),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    ThemeData theme = Theme.of(context);
    ColorScheme colorScheme = theme.colorScheme;
    TextTheme textTheme = theme.textTheme;

    List<GlobalKey> btnKeys = [GlobalKey(), GlobalKey(), GlobalKey()];
    List<Offset> btnPos = [];
    List<Size> btnSize = [];

    GlobalKey overlayKey = GlobalKey();

    OverlayEntry row = _createRow(btnKeys: btnKeys);

    SchedulerBinding.instance.addPostFrameCallback((_) {
      OverlayState state = overlayKey.currentState as OverlayState;

      if (overlayKey.currentContext == null) {
        throw Exception('overlayKey.currentContext is null');
      }

      var overlayPos =
          (overlayKey.currentContext?.findRenderObject() as RenderBox)
              .localToGlobal(Offset.zero);

      for (var element in btnKeys) {
        if (element.currentContext == null) {
          throw Exception('element.currentContext is null');
        }
        var readerBox = element.currentContext?.findRenderObject() as RenderBox;
        var readerSize = readerBox.size;
        var readerPos =
            readerBox.localToGlobal(Offset(-overlayPos.dx, -overlayPos.dy));

        btnPos.add(readerPos);
        btnSize.add(readerSize);
      }

      state.insert(OverlayEntry(builder: (context) {
        return _createSticky(
          posX: btnPos[_selected].dx,
          posY: btnPos[_selected].dy,
          height: btnSize[_selected].height,
          width: btnSize[_selected].width,
          colorScheme: colorScheme,
        );
      }), below: row);
    });

    return SizedBox(
      width: 300,
      height: 50,
      child: Overlay(key: overlayKey, initialEntries: [row]),
    );
  }
}

Full Code

I tried many methods before posting this post, which made the code a bit messy. Originally AttributeSwitcher was a StatelessWidget, and the selection switching logic was handled in the parent Widget. If you answer my question, I will be sincerely grateful!

I hope that when I click on another option, the selection indicator moves to another option, instead of jumping directly to another option.

2

Answers


  1. Chosen as BEST ANSWER

    My guess is that the Sticky(OverlayEntry) is rebuilt and inserted into the Widget with each setState build, so the previous state disappears and a new Sticky is used directly with the new parameter, which is why it behaves as if there is no animation. Now I used the method of this reply and now it works correctly.


  2. try to give curve and duration to the animated container also

    here is an example of code I used and works well

        AnimatedPositioned(
                  curve: Curves.easeOutQuart,
                  duration: const Duration(milliseconds: 800),
                  left: _isGiveaway ? 10.w : (context.width / 2),
                  top: 0,
                  bottom: 0,
                  right: _isGiveaway ? (context.width / 2) : 10.w,
                  child: AnimatedContainer(
                    curve: Curves.easeOutQuart,
                    duration: const Duration(milliseconds: 800),
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(10),
                      color: context.primaryColor,
                    ),
                  ),
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search