buildFluentSpinButton function
- FluentSpinButtonBaseState state,
- FluentSpinButtonStyle style,
- Set<
WidgetState> states
Renders a spin button from a resolved state and style.
The third of the three-function recomposition contract. Takes FluentSpinButtonBaseState rather than FluentSpinButtonState on purpose: it never reads appearance or size, so a consumer can supply their own style and still use Fluent's layout, rules and stepper column.
states is the live interaction set of the control — hover, press and
disabled. Each stepper resolves its own set independently, which is what
makes a stepper hold at rest while the field around it is hovered.
The only thing that animates is the focus underline, which grows from the
centre — useSpinButtonStyles.styles.ts declares exactly one transition,
transform on the root ::after. That bar is FluentInputFocusUnderline,
whose fluentInputFocusUnderlineEnter and fluentInputFocusUnderlineExit
are bit-for-bit the pair this file used to keep privately: durationNormal
on curveDecelerateMid in, durationUltraFast on curveAccelerateMid
out. Reduced motion is handled inside that widget.
Implementation
Widget buildFluentSpinButton(
FluentSpinButtonBaseState state,
FluentSpinButtonStyle style,
Set<WidgetState> states,
) {
final radius = style.borderRadius?.resolve(states) ?? BorderRadius.zero;
final borderWidth = style.borderWidth?.resolve(states) ?? FluentStroke.none;
final borderColor = style.borderColor?.resolve(states);
final background = style.backgroundColor?.resolve(states);
final ruleColor = style.bottomRuleColor?.resolve(states);
final ruleWidth = style.bottomRuleWidth?.resolve(states) ?? FluentStroke.none;
final focusColor = style.focusUnderlineColor?.resolve(states);
final focusWidth =
style.focusUnderlineWidth?.resolve(states) ?? FluentStroke.none;
final foreground = style.foregroundColor?.resolve(states);
final textStyle = style.textStyle?.resolve(states);
final padding = style.padding?.resolve(states) ?? EdgeInsets.zero;
final contentPadding =
style.contentPadding?.resolve(states) ?? EdgeInsets.zero;
final minimumSize = style.minimumSize?.resolve(states) ?? Size.zero;
Widget field = Padding(padding: contentPadding, child: state.field);
if (textStyle != null || foreground != null) {
field = DefaultTextStyle.merge(
style: (textStyle ?? const TextStyle()).copyWith(color: foreground),
child: field,
);
}
final content = Row(
// Not `stretch`: the control is laid out under unbounded height (a Column,
// a Center) as often as not, and stretch would demand a bounded one. The
// padding table already makes the field box exactly as tall as the stepper
// column — 6 + 20 + 6 and 4 + 16 + 4 — so centring lands on the same pixel.
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(child: field),
// The steppers duplicate the increment and decrement actions the control
// already publishes on itself, so announcing them again would read the
// field twice.
ExcludeSemantics(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
FluentSpinButtonStepper(
direction: FluentSpinButtonStepperDirection.increase,
style: style,
onPressed: state.onIncrease,
),
FluentSpinButtonStepper(
direction: FluentSpinButtonStepperDirection.decrease,
style: style,
onPressed: state.onDecrease,
),
],
),
),
],
);
// The two rules are overlays rather than a fourth border side: Flutter
// refuses a rounded rectangle whose sides disagree in colour, and the bottom
// edge of an Outline input is a different token from its other three. Figma
// models them as separate rectangles anyway.
//
// Both carry the field's own bottom corners. The radius has to be painted on
// a TALLER box and clipped back — Skia scales every corner by
// `min(edge / sum-of-radii-on-that-edge)`, so a 4px corner on a 1px rule
// ships as 0.5 and the ends read square against a rounded field.
// FluentInputUnderline is that clip, and it is upstream's own trick:
// `height: max(2px, borderRadiusMedium)` plus
// `clipPath: inset(calc(100% - 2px) 0 0 0)` on the `::after`.
final ruleRadius = BorderRadius.only(
bottomLeft: radius.bottomLeft,
bottomRight: radius.bottomRight,
);
return Stack(
children: <Widget>[
ConstrainedBox(
constraints: BoxConstraints(
minHeight: minimumSize.height,
minWidth: minimumSize.width,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: background,
borderRadius: radius,
border: borderWidth > 0 && borderColor != null
? Border.all(color: borderColor, width: borderWidth)
: null,
),
child: Padding(padding: padding, child: content),
),
),
if (ruleColor != null && ruleWidth > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: ruleWidth,
child: FluentInputUnderline(
color: ruleColor,
thickness: ruleWidth,
borderRadius: ruleRadius,
),
),
if (focusColor != null && focusWidth > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: focusWidth,
child: FluentInputFocusUnderline(
focused: state.focused,
color: focusColor,
borderRadius: ruleRadius,
thickness: focusWidth,
),
),
],
);
}