buildFluentDropdown function
- FluentDropdownBaseState state,
- FluentDropdownStyle style,
- Set<
WidgetState> states
Renders a dropdown trigger from a resolved state and style.
The third of the three-function recomposition contract. Takes FluentDropdownBaseState rather than FluentDropdownState on purpose: it never reads the appearance or the size, so a consumer can supply their own style and still use Fluent's layout and accent animation. It renders the trigger only — the popup is an Overlay concern, and its surface has its own builder in buildFluentDropdownSurface.
Motion
One thing animates, and it is the bottom accent rule: upstream's ::after
is transform: scaleX(0) at rest and scaleX(1) under :focus-within, so
the brand rule grows from the centre outwards. See
fluentDropdownAccentEnter and fluentDropdownAccentExit for the two
durations. Nothing else moves — the border changes on the frame the pointer
arrives, because useDropdownStyles declares no transition on it.
states is the live interaction set from FluentInteractive.
Implementation
Widget buildFluentDropdown(
FluentDropdownBaseState state,
FluentDropdownStyle style,
Set<WidgetState> states,
) {
final radius = style.borderRadius?.resolve(states) ?? FluentRadius.allMedium;
final borderWidth = style.borderWidth?.resolve(states) ?? FluentStroke.none;
final borderColor = style.borderColor?.resolve(states);
final underlineColor = style.underlineColor?.resolve(states);
final accentColor = style.accentColor?.resolve(states);
final accentWidth = style.accentWidth?.resolve(states) ?? FluentStroke.thick;
final foreground = state.value == null
? style.placeholderColor?.resolve(states)
: style.foregroundColor?.resolve(states);
final textStyle = style.textStyle?.resolve(states);
final padding = style.padding?.resolve(states) ?? EdgeInsets.zero;
final gap = style.gap?.resolve(states) ?? FluentSpacing.mNudge;
final chevronColor = style.chevronColor?.resolve(states);
final chevronSize = style.chevronSize?.resolve(states) ?? FluentSize.size200;
final chevronPadding =
style.chevronPadding?.resolve(states) ?? EdgeInsets.zero;
final minimumSize = style.minimumSize?.resolve(states) ?? Size.zero;
Widget label = Padding(
padding: padding,
// heightFactor, or the Align fills the whole loose height it is offered and
// a dropdown in a Column becomes as tall as the screen.
child: Align(
alignment: AlignmentDirectional.centerStart,
heightFactor: 1,
child: state.value ?? state.placeholder ?? const SizedBox.shrink(),
),
);
if (textStyle != null || foreground != null) {
label = DefaultTextStyle.merge(
style: (textStyle ?? const TextStyle()).copyWith(color: foreground),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: label,
);
}
final content = Row(
children: <Widget>[
Expanded(child: label),
SizedBox(width: gap),
Padding(
padding: chevronPadding,
child: IconTheme.merge(
data: IconThemeData(color: chevronColor, size: chevronSize),
child: state.chevron,
),
),
],
);
// CSS box model: a border that exists takes space, so the content sits inside
// it — 1px on every side for Outline and the filled appearances (whose
// transparent border still counts), the bottom only for Transparent. A null
// colour is no border at all. The bottom side is as wide as the others, as a
// CSS `border-width` makes it; 1px when there are no others.
final side = borderColor == null ? FluentStroke.none : borderWidth;
final widths = EdgeInsets.fromLTRB(
side,
side,
side,
underlineColor == null || side > 0 ? side : FluentStroke.thin,
);
return Stack(
// The bar overhangs a borderless root: see below.
clipBehavior: Clip.none,
// 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 /
// 40 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,
),
// Background, then border, then content, then the bar: CSS's paint
// order for a root and its positioned `::after`. The border is the
// painter `FluentInput` uses, which joins the darker bottom side to the
// others on the CSS corner diagonal.
child: DecoratedBox(
decoration: BoxDecoration(
color: style.backgroundColor?.resolve(states),
borderRadius: radius,
),
child: CustomPaint(
painter: FluentInputBorderPainter(
radius: radius,
borderColor: borderColor,
borderWidth: side,
bottomBorderColor: underlineColor,
bottomBorderWidth: widths.bottom,
),
child: Padding(padding: widths, child: content),
),
),
),
// `::after { left: -1px; right: -1px; bottom: -1px }` against the padding
// box: flush with the border box when the sides are 1px, a pixel past it
// each side on Transparent, which has none. Its 4px bottom radii are its
// own, not the root's, so they stay rounded on Transparent's square root.
if (accentColor != null)
Positioned(
left: side - FluentStroke.thin,
right: side - FluentStroke.thin,
bottom: 0,
height: accentWidth,
child: FluentInputFocusUnderline(
focused: state.focused || state.open,
color: accentColor,
thickness: accentWidth,
borderRadius: _accentRadius,
),
),
],
);
}