How to emulate Android's showAsAction="ifRoom" in Flutter?

472

This is not supported out of the box, but you can replicate that behavior. I've created the following widget:

import 'package:flutter/material.dart';

class CustomActionsRow extends StatelessWidget {
  final double availableWidth;
  final double actionWidth;
  final List<CustomAction> actions;

  const CustomActionsRow({
    @required this.availableWidth,
    @required this.actionWidth,
    @required this.actions,
  });

  @override
  Widget build(BuildContext context) {
    actions.sort(); // items with ShowAsAction.NEVER are placed at the end

    List<CustomAction> visible = actions
        .where((CustomAction customAction) => customAction.showAsAction == ShowAsAction.ALWAYS)
        .toList();

    List<CustomAction> overflow = actions
        .where((CustomAction customAction) => customAction.showAsAction == ShowAsAction.NEVER)
        .toList();

    double getOverflowWidth() => overflow.isEmpty ? 0 : actionWidth;

    for (CustomAction customAction in actions) {
      if (customAction.showAsAction == ShowAsAction.IF_ROOM) {
        if (availableWidth - visible.length * actionWidth - getOverflowWidth() > actionWidth) { // there is enough room
          visible.insert(actions.indexOf(customAction), customAction); // insert in its given position
        } else { // there is not enough room
          if (overflow.isEmpty) {
            CustomAction lastOptionalAction = visible.lastWhere((CustomAction customAction) => customAction.showAsAction == ShowAsAction.IF_ROOM, orElse: () => null);
            if (lastOptionalAction != null) {
              visible.remove(lastOptionalAction); // remove the last optionally visible action to make space for the overflow icon
              overflow.add(lastOptionalAction);
              overflow.add(customAction);
            } // else the layout will overflow because there is not enough space for all the visible items and the overflow icon
          } else {
            overflow.add(customAction);
          }
        }
      }
    }

    return Row(
      mainAxisAlignment: MainAxisAlignment.end,
      children: <Widget>[
        ...visible.map((CustomAction customAction) => customAction.visibleWidget),
        if (overflow.isNotEmpty) PopupMenuButton(
          itemBuilder: (BuildContext context) => [
            for (CustomAction customAction in overflow) PopupMenuItem(
              child: customAction.overflowWidget,
            )
          ],
        )
      ],
    );
  }
}

class CustomAction implements Comparable<CustomAction> {
  final Widget visibleWidget;
  final Widget overflowWidget;
  final ShowAsAction showAsAction;

  const CustomAction({
    this.visibleWidget,
    this.overflowWidget,
    @required this.showAsAction,
  });

  @override
  int compareTo(CustomAction other) {
    if (showAsAction == ShowAsAction.NEVER && other.showAsAction == ShowAsAction.NEVER) {
      return 0;
    } else if (showAsAction == ShowAsAction.NEVER) {
      return 1;
    } else if (other.showAsAction == ShowAsAction.NEVER) {
      return -1;
    } else {
      return 0;
    }
  }
}

enum ShowAsAction {
  ALWAYS,
  IF_ROOM,
  NEVER,
}

You need to indicate the total available width for all icons (including the overflow icon), as well as the width of each action (they all need to be of the same width). Here is an example of using it:

return Scaffold(
  appBar: AppBar(
    title: const Text("Title"),
    actions: <Widget>[
      CustomActionsRow(
        availableWidth: MediaQuery.of(context).size.width / 2, // half the screen width
        actionWidth: 48, // default for IconButtons
        actions: [
          CustomAction(
            overflowWidget: GestureDetector(
              onTap: () {},
              child: const Text("Never 1"),
            ),
            showAsAction: ShowAsAction.NEVER,
          ),
          CustomAction(
            visibleWidget: IconButton(
              onPressed: () {},
              icon: Icon(Icons.ac_unit),
            ),
            showAsAction: ShowAsAction.ALWAYS,
          ),
          CustomAction(
            visibleWidget: IconButton(
              onPressed: () {},
              icon: Icon(Icons.cancel),
            ),
            overflowWidget: GestureDetector(
              onTap: () {},
              child: const Text("If Room 1"),
            ),
            showAsAction: ShowAsAction.IF_ROOM,
          ),
          CustomAction(
            visibleWidget: IconButton(
              onPressed: () {},
              icon: Icon(Icons.ac_unit),
            ),
            showAsAction: ShowAsAction.ALWAYS,
          ),
          CustomAction(
            visibleWidget: IconButton(
              onPressed: () {},
              icon: Icon(Icons.cancel),
            ),
            overflowWidget: GestureDetector(
              onTap: () {},
              child: const Text("If Room 2"),
            ),
            showAsAction: ShowAsAction.IF_ROOM,
          ),
          CustomAction(
            overflowWidget: GestureDetector(
              onTap: () {},
              child: const Text("Never 2"),
            ),
            showAsAction: ShowAsAction.NEVER,
          ),
        ],
      ),
    ],
  ),
);

Note that I haven't added any asserts, but you should always provide the visibleWidget for actions where showAsAction is either ALWAYS or IF_ROOM, and always provide overflowWidget for actions where showAsAction is either IF_ROOM or NEVER.

Here is the result of using the code above:

enter image description here enter image description here

You can customize the CustomActionsRow as per your requirements, for example you might have actions of different widths in which case you would want each CustomAction to supply its own width etc.

Share:
472
Dancovich
Author by

Dancovich

Updated on December 10, 2022

Comments

  • Dancovich
    Dancovich over 1 year

    In my Flutter app I have a screen that is a MaterialApp with a Scaffold widget as it's home.

    The appBar property of this Scaffold is an AppBar widget with the actions property filled with some actions and a popup menu to house the rest of the options.

    The thing is, as I understand a child of AppBar actions list can either be a generic widget (it will be added as an action) or an instance of PopupMenuButton, in which case it will add the platform specific icon that when triggered opens the AppBar popup menu.

    On native Android that's not how it works. I just need to inflate a menu filled with menu items and each item either can be forced to be an action, forced to NOT be an action or have the special value "ifRoom" that means "be an action if there is space, otherwise be an item inside de popup menu".

    Is there a way in Flutter to have this behavior without having to write a complex logic to populate the "actions" property of the AppBar?

    I've looked into both AppBar and PopupMenuButton documentations and so far nothing explains how to do such a thing. I could simulate the behavior but then I would have to actually write a routine to calculate the available space and build the "actions" list accordingly.

    Here's a typical Android menu that mixes actions and popup menu entries. Notice the "load_game" entry can be an action if there is room and will become a menu entry if not.

    <?xml version="1.0" encoding="utf-8"?>
    <menu xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:id="@+id/new_game"
              android:icon="@drawable/ic_new_game"
              android:title="@string/new_game"
              android:showAsAction="always"/>
        <item android:id="@+id/load_game"
              android:icon="@drawable/ic_load_game"
              android:title="@string/load_game"
              android:showAsAction="ifRoom"/>
        <item android:id="@+id/help"
              android:icon="@drawable/ic_help"
              android:title="@string/help"
              android:showAsAction="never" />
    </menu>
    

    On the other hand in Flutter I have to decide ahead of time if the options will be an action or a menu entry.

    AppBar(
      title: Text("My Incredible Game"),
      primary: true,
      actions: <Widget>[
        IconButton(
          icon: Icon(Icons.add),
          tooltip: "New Game",
          onPressed: null,
        ),
        IconButton(
          icon: Icon(Icons.cloud_upload),
          tooltip: "Load Game",
          onPressed: null,
        ),
        PopupMenuButton(
          itemBuilder: (BuildContext context) {
            return <PopupMenuEntry>[
              PopupMenuItem(
                child: Text("Help"),
              ),
            ];
          },
        )
      ],
    )
    

    What I hoped would work is that the AppBar actually had just a single "action" property instead of "actions". That property would be just a widget allowing me to have anything so if I wanted just a list of actions then a Row filled with IconButton's would suffice.

    Along with that each PopupMenuItem inside the PopupMenuButton would have a "showAsAction" property. If one or more PopupMenuItem inside the PopupMenuButton was checked to be an action or "ifRoom" and there is room, then the PopupMenuButton would expand horizontally and place these items as actions.