Expand description
The Material 3 Expressive split button: a leading action segment joined to a trailing menu-trigger segment across a hairline gap, reading as one connected shape.
split_button keeps its original four-argument shape — label, the
controlled open flag, the leading on_press action, and the trailing
on_open menu callback — and every reference axis is a chained builder on
top: SplitButtonView::size, SplitButtonView::variant,
SplitButtonView::shape, SplitButtonView::items,
SplitButtonView::menu_style.
§Two segments, two focus targets
The reference builds the halves as two independent Focus nodes inside one
FocusTraversalGroup, each with its own InkWell, its own
canRequestFocus/skipTraversal gate and its own key handler
(m3e_split_button_segments.dart:100 and :335). This port matches that
structurally: each half is a real child widget in its own pod, so it is its
own hit target, its own focus target (a Key event routes down the
focus path to exactly one of them), its own semantics node, and
separately disable-able — SplitButtonView::enabled gates the whole
control (the reference’s IgnorePointer(ignoring: !enabled)) while
SplitButtonView::trailing_enabled disables the menu trigger alone,
making real the leadingEnabled/trailingEnabled split the reference
already carries as two distinct variables fed from one prop
(m3e_split_button_content.dart:212-:213).
Honest limitation, the same one crate::button records: frust has
no keyboard focus traversal — nothing hands a button focus, and neither
segment claims focus on a press (a tap-shows-a-focus-ring button is the
wrong behavior on touch). The focus ring, the focus-widened gap and the
Enter/Space activation below are ported, gated on the authoritative focus
reads and unit-tested by seeding a pod’s recorded focus path directly; they
become live the moment focus routing can reach a button.
§Sizes
Segment height / trailing width / leading paddings / icon, from
M3ESplitButtonTheme’s tables (m3e_split_button_theme.dart:76-:210),
with the label type role from _LeadingContent
(m3e_split_button_widgets.dart:31):
| Size | Height | Trailing width | Left pad | Label right pad | Icon | Label role |
|---|---|---|---|---|---|---|
SplitButtonSize::Xs | 32 | 48 | 12 | 10 | 20 | label_small |
SplitButtonSize::Sm (default) | 40 | 48 | 16 | 12 | 24 | label_medium |
SplitButtonSize::Md | 56 | 56 | 24 | 24 | 24 | label_large |
SplitButtonSize::Lg | 96 | 96 | 48 | 48 | 32 | title_medium |
SplitButtonSize::Xl | 136 | 136 | 64 | 64 | 40 | title_large |
The trailing width column is the reference’s own three-way resolution
(unselected = left inner pad + icon + right outer pad; selected = side pad ×
2 + icon; circle = the segment height), floored at
SPLIT_MIN_TAP_TARGET — all three agree for every size, which
the_three_trailing_width_formulas_agree pins rather than assumes.
§Variants
Container / content roles are the plain button’s
(m3e_button_theme.dart:111/:127, reached through
_resolveColorsAndShapes): SplitButtonVariant::Filled primary /
on_primary, SplitButtonVariant::Tonal secondary_container /
on_secondary_container, SplitButtonVariant::Elevated
surface_container_low / primary (plus a shadow),
SplitButtonVariant::Outlined transparent + outline hairline /
primary. There is no Text variant: the reference asserts it away
(m3e_split_buttons.dart:76).
§Connected geometry and the spring clip
Each segment resolves four independent corner radii and springs to
them, then clips its own content to the animating shape — the reference’s
M3ERadiusAndPaddingMotion feeding a Material(clipBehavior: antiAlias, borderRadius: animatedRadius) (m3e_split_button_segments.dart:235,
:330). The outer (world-facing) corners take the shape family’s radius;
the two inner (adjacent) corners resolve per state:
| State | Leading inner | Trailing inner | Trailing outer |
|---|---|---|---|
| resting | inner | inner | outer |
| hovered | hovered | hovered | outer |
| pressed | pressed | pressed | outer |
| menu open | — | height / 2 | height / 2 |
_leadingRadii/_trailingRadii (m3e_split_button_style.dart:166/:182)
in precedence order — the open (selected) state outranks pressed, which
outranks hovered. The per-size inner column (4/4/4/8/12) lands exactly on
this crate’s extra_small/small/medium shape tokens and therefore
resolves from the live frust::Theme; the hovered (8/12/12/20/20),
pressed (2/2/2/4/6) and square-outer (8/10/14/24/34) columns have no
whole-table token to resolve from and stay literal — the same
partial-coincidence rule crate::toggle_button applies.
The spring is SPLIT_SHAPE_SPRING — stiffness 380, damping ratio 0.55,
the expressiveSpatialPress preset every button in this family defaults to
(m3e_base_button_state.dart:183), i.e.
crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS. It is a named constant
rather than a theme read because a press starts in the event pass, which
never threads a theme; the_shape_spring_matches_expressive_spatial_press
is the tripwire. [ShapeMotion] carries the retarget contract
crate::button’s RadiusPaddingMotion documents — mount-frame snap,
a 0.1px dead band, mid-flight continuity from the value being painted right
now, and a 1.5× overshoot cap — re-pinned here directly, since that type is
private to a sibling module.
§The chevron: a tween, not a spring
Only the clip springs. The chevron itself is an AnimatedRotation
(m3e_split_button_segments.dart:276): CHEVRON_OPEN_TURNS (half a
turn) over CHEVRON_ROTATION_DURATION (120ms) on Curve::EaseOut,
transcribed as a duration-driven frust::AnimationController rather than
a fling. It also carries a per-size optical nudge
(SplitButtonTrailingAlignment::OpticalCenter, the default) that drops to
zero while the menu is open, exactly as _computeTrailingGeometry does.
§Menu styles
SplitButtonMenuStyle is the ported half of the reference’s three-way
M3ESplitButtonMenuStyle, and SplitButtonView::menu_route is the
selector: it hands back the presentation this button’s style calls for,
built from the same SplitButtonView::items list.
SplitButtonMenuStyle::Popup→SplitButtonMenuRoute::Popup, acrate::menu()undercrate::overlay::anchored, anchored to the trailing segment at the reference’sbottomStart+ 4dppopupOffset. Likecrate::button_group::ButtonGroupView::overflow_menu, this is a second, app-mounted view (the top of the app’s ownfrust::Stack, or a transparent navigator page): the trigger paints inside this widget’s own box and a leaf widget has no portal to reach out through. WireSplitButtonView::menu_anchorso it has a rect to place against.SplitButtonMenuStyle::BottomSheet→SplitButtonMenuRoute::Sheet, acrate::bottom_sheetover the same items, to hand tocrate::show_bottom_sheet— which needs afrust::NavigatorControllera leaf widget cannot reach, so presentation stays the app’s exactly as it does forbutton_group’s sheet-style overflow. The staged back-press exit and the scrim are the sheet tier’s, not this module’s.nativeis descoped (plan decision): Flutter’sshowMenuis a platform-menu route with no analogue here, and the popup style already covers every case it served.
Both routes render the item list through the one item-list surface this
catalog owns (crate::menu::menu_panel) rather than a second row
renderer — a porting decision, since the reference’s sheet rows
(m3e_split_button_bottom_sheet.dart:105) are a near-duplicate of its own
menu rows.
§Not ported
M3ESplitButtonDecoration’s gradient layers (the plain button’s
crate::ButtonDecoration seam is where that belongs), M3EButtonSize.custom,
the multi-select bottom sheet and its checkbox style, menuBuilder/
m3eMenuBuilder (an app composes crate::menu() itself), tooltips and
onLongPress. Each is either a seam this catalog already owns elsewhere or
a Flutter-plumbing concept with no frust analogue — the same mapping table
crate::button records.
§Attribution
See plugins/material/NOTICE’s “MIT License — Additional Copyright Holders
(Vendored Components)” section (Mudit Purohit / m3e_buttons) and its Module
Attribution Header Convention.
Structs§
- Split
Button Item - One selectable entry in a split button’s menu — the reference’s
M3ESplitButtonItem(models/m3e_split_button_item.dart). - Split
Button View - A declarative split button. See the module docs.
- Split
Button Widget - The retained widget for a
SplitButtonView: the two segment pods plus the gap between them. It paints nothing of its own — each segment owns its container, clip, state layer and content.
Enums§
- Split
Button Menu Route - The presentation a split button’s
SplitButtonMenuStylecalls for, asSplitButtonView::menu_routeresolves it. See the module docs’ Menu styles section for how an app mounts each. - Split
Button Menu Style - Which presentation the trailing trigger’s menu takes — the ported half of
the reference’s
M3ESplitButtonMenuStyle. See the module docs’ Menu styles section (the third,native, is descoped). - Split
Button Shape - The split button’s corner-radius family — the reference’s
M3EButtonShape(m3e_button_enums.dart:45) as_computeRadiiconsumes it. - Split
Button Size - The split button’s size tier — the reference’s
M3EButtonSize(m3e_button_enums.dart:64) asM3ESplitButtonThememeasures it. See the module docs’ Sizes table. - Split
Button Trailing Alignment - How the trailing chevron is centred — the reference’s
M3ESplitButtonTrailingAlignment. - Split
Button Variant - The split button’s container treatment — the reference’s
M3EButtonStyleminustext, whichM3ESplitButtonasserts away (m3e_split_buttons.dart:76). See the module docs’ Variants section.
Constants§
- CHEVRON_
OPEN_ TURNS - How far the chevron rotates when the menu opens, in turns —
chevronOpenTurns(:16). - CHEVRON_
ROTATION_ DURATION - The chevron rotation’s duration —
AnimatedRotation(duration: 120ms)(m3e_split_button_segments.dart:277). A tween, not a spring; see the module docs. - SPLIT_
ELEVATED_ INNER_ GAP - The gap an
SplitButtonVariant::Elevatedsplit button takes instead, in logical px —elevatedInnerGap(:15), widened so two shadows do not merge. - SPLIT_
FOCUS_ RING_ OUTSET - How far a focus ring extends past its segment, in logical px — the
kFocusRingGap + kFocusRingWidthsum_computeSegmentGeometryadds to the inner gap while either half is focused (m3e_split_button_content.dart:296). - SPLIT_
INNER_ GAP - The gap between the two segments, in logical px —
innerGap(:14). - SPLIT_
MIN_ TAP_ TARGET - The minimum tap target the control as a whole reserves, in logical px —
M3ESplitButtonTheme.minTapTarget(:13). A segment shorter than this (thexs/smrows) is centred inside it rather than stretched. - SPLIT_
SHAPE_ SPRING - The segment shape (clip) spring: stiffness 380, damping ratio 0.55, mass 1 —
expressiveSpatialPress, i.e.crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS. See the module docs for why it is a constant rather than a theme read. - TRAILING_
SELECTED_ CORNER_ PERCENT - The open trailing segment’s corner radius as a percentage of its height —
trailingInnerSelectedCornerPercent(:17), i.e. a pill.
Functions§
- Split
Button - PascalCase alias for
split_button, matching the catalog’s view-fn vocabulary. - split_
button - Create a split button labelled
label, with a leadingon_pressaction and a trailingon_openmenu callback. - split_
button_ item - Create a menu item labelled
label.