buildFluentSpinButton function

Widget buildFluentSpinButton(
  1. FluentSpinButtonBaseState state,
  2. FluentSpinButtonStyle style,
  3. 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,
          ),
        ),
    ],
  );
}