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, border 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.
Paint order is upstream's: the fill, the content (steppers included), the
border over both — upstream draws it on ::before at z-index: 10, so a
hovered stepper never covers it — and the focus bar (::after, 20) on top.
That ::before is an absolute overlay, so unlike buildFluentInput the
border does not inset the content.
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 durationNormal in and durationUltraFast out, both on CSS ease —
upstream's curve tokens sit in transitionDelay, which the browser drops,
and the port ports what renders. 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;
// The `<input>` spans the control's full height, and so does its cursor;
// its text sits in the middle of it. `heightFactor` keeps it at its own
// height when the parent's is loose, and centres it in a taller tight one.
Widget field = MouseRegion(
cursor: style.mouseCursor?.resolve(states) ?? MouseCursor.defer,
child: Align(
alignment: AlignmentDirectional.centerStart,
heightFactor: 1,
child: Padding(padding: contentPadding, child: state.field),
),
);
if (textStyle != null || foreground != null) {
field = DefaultTextStyle.merge(
style: (textStyle ?? const TextStyle()).copyWith(color: foreground),
child: field,
);
}
// Upstream's grid: two `1fr` rows the `<input>` spans, a button at the
// start of each. At the natural 32 / 24 the rows are the buttons, 16 / 12;
// a taller box leaves each button atop its half, as Chrome does.
Widget stepper(FluentSpinButtonStepperDirection direction) => Expanded(
child: Align(
alignment: Alignment.topCenter,
child: FluentSpinButtonStepper(
direction: direction,
style: style,
onPressed: direction == FluentSpinButtonStepperDirection.increase
? state.onIncrease
: state.onDecrease,
),
),
);
// The grid's `auto` height is the taller of the field and the two stepper
// rows. The stepper column's size is known from the style, so the field
// sizes the box and the column is pinned over its end: no intrinsic pass,
// which a caller's field (a LayoutBuilder, say) may be unable to answer.
const rest = <WidgetState>{};
final stepperBox = style.stepperSize?.resolve(rest) ?? const Size(24, 16);
final content = ConstrainedBox(
constraints: BoxConstraints(minHeight: stepperBox.height * 2),
child: Stack(
fit: StackFit.passthrough,
children: <Widget>[
Padding(
// Upstream's grid `columnGap: spacingHorizontalXS`, at both sizes.
padding: EdgeInsetsDirectional.only(
end: stepperBox.width + FluentSpacing.xs,
),
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.
PositionedDirectional(
end: 0,
top: 0,
bottom: 0,
width: stepperBox.width,
child: ExcludeSemantics(
child: Column(
children: <Widget>[
stepper(FluentSpinButtonStepperDirection.increase),
stepper(FluentSpinButtonStepperDirection.decrease),
],
),
),
),
],
),
);
// A null colour is no side at all, whatever the width says.
final side = borderColor == null ? FluentStroke.none : borderWidth;
// A border with no sides is drawn square: upstream's underline zeroes the
// radius of `::before` ("corners look strange if rounded") and of the focus
// bar, and keeps the root's for the fill. Underline is the only appearance
// with no side border, so that is what tells it apart here.
// ponytail: keyed on the side width, not a style property of its own; add
// one if a caller ever needs a rounded bottom-only border.
final edge = side > 0 ? radius : BorderRadius.zero;
return Stack(
// Passthrough, so a parent's tight height stretches the box itself, as a
// CSS `height` would. A loose Stack laid the box out at its own 24 / 32 and
// pinned the bar to the bottom of the taller Stack, below it.
fit: StackFit.passthrough,
children: <Widget>[
ConstrainedBox(
constraints: BoxConstraints(
minHeight: minimumSize.height,
minWidth: minimumSize.width,
),
child: DecoratedBox(
decoration: BoxDecoration(color: background, borderRadius: radius),
child: CustomPaint(
foregroundPainter: FluentInputBorderPainter(
radius: edge,
borderColor: borderColor,
borderWidth: side,
bottomBorderColor: ruleColor,
bottomBorderWidth: ruleColor == null ? side : ruleWidth,
),
child: Padding(padding: padding, child: content),
),
),
),
if (focusColor != null && focusWidth > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: focusWidth,
child: FluentInputFocusUnderline(
focused: state.focused,
color: focusColor,
borderRadius: BorderRadius.only(
bottomLeft: edge.bottomLeft,
bottomRight: edge.bottomRight,
),
thickness: focusWidth,
),
),
],
);
}