Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions packages/mix/lib/src/core/internal/mix_interaction_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,23 @@ class MixInteractionDetector extends StatefulWidget {
super.key,
required this.child,
this.controller,
this.initialStates = const {},
this.enabled = true,
this.onHoverChange,
this.onPointerPositionChange,
});

final Widget child;
final WidgetStatesController? controller;

/// States used to seed an internally owned controller.
///
/// This set is read only when the detector is first mounted without an
/// external [controller]. The states are copied before [enabled] is
/// synchronized, which clears hover and press while disabled. Later changes
/// to this property are not reapplied to the active controller.
final Set<WidgetState> initialStates;

final bool enabled;
final ValueChanged<bool>? onHoverChange;
final ValueChanged<PointerPosition>? onPointerPositionChange;
Expand All @@ -41,14 +51,12 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
void initState() {
super.initState();
_cursorPositionNotifier = PointerPositionNotifier();
if (widget.controller == null) {
_internalController = WidgetStatesController(widget.initialStates);
}
_syncDisabledState();
}

/// Creates an internal controller with initial disabled state if needed.
WidgetStatesController _createInternalController() {
return WidgetStatesController({if (!widget.enabled) .disabled});
}

/// Syncs disabled state and clears transients when disabling.
void _syncDisabledState() {
_effectiveController.update(.disabled, !widget.enabled);
Expand All @@ -63,9 +71,7 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
/// Handles state controller changes between external and internal.
void _handleControllerChange(MixInteractionDetector oldWidget) {
if (widget.controller == null) {
_internalController ??= WidgetStatesController(
oldWidget.controller?.value ?? {},
);
_internalController = WidgetStatesController(oldWidget.controller!.value);
} else {
_internalController?.dispose();
_internalController = null;
Expand Down Expand Up @@ -165,8 +171,7 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
}

WidgetStatesController get _effectiveController =>
widget.controller ??
(_internalController ??= _createInternalController());
widget.controller ?? _internalController!;

@override
void didUpdateWidget(MixInteractionDetector oldWidget) {
Expand Down
29 changes: 25 additions & 4 deletions packages/mix/lib/src/core/providers/widget_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension on Set<WidgetState> {
bool get hasDragged => contains(WidgetState.dragged);
bool get hasSelected => contains(WidgetState.selected);
bool get hasError => contains(WidgetState.error);
bool get hasScrolledUnder => contains(WidgetState.scrolledUnder);
}

/// Provider for widget state information using Flutter's [InheritedModel].
Expand All @@ -29,19 +30,33 @@ class WidgetStateProvider extends InheritedModel<WidgetState> {
pressed = states.hasPressed,
dragged = states.hasDragged,
selected = states.hasSelected,
error = states.hasError;
error = states.hasError,
scrolledUnder = states.hasScrolledUnder;

/// Retrieves the [WidgetStateProvider] from the widget tree.
///
/// If [state] is provided, creates a dependency only on that specific state.
/// This enables granular rebuilds when only specific states change.
///
/// Passing a null [state] creates a dependency on *every* aspect, so callers
/// that only need to know whether a scope exists should use [hasProvider]
/// instead of comparing `of(context)` against null.
static WidgetStateProvider? of(BuildContext context, [WidgetState? state]) {
return InheritedModel.inheritFrom<WidgetStateProvider>(
context,
aspect: state,
);
}

/// Whether a [WidgetStateProvider] scope exists above [context].
///
/// Unlike [of], this performs a read-only lookup and does **not** register a
/// dependency on any state aspect, so a widget calling it will not rebuild
/// when unrelated states (hover, press, etc.) change.
static bool hasProvider(BuildContext context) {
return context.getInheritedWidgetOfExactType<WidgetStateProvider>() != null;
}

/// Checks if a specific [WidgetState] is currently active.
///
/// Returns false if no [WidgetStateProvider] is found in the widget tree.
Expand All @@ -59,7 +74,7 @@ class WidgetStateProvider extends InheritedModel<WidgetState> {
.dragged => model.dragged,
.selected => model.selected,
.error => model.error,
.scrolledUnder => false,
.scrolledUnder => model.scrolledUnder,
};
}

Expand All @@ -84,6 +99,9 @@ class WidgetStateProvider extends InheritedModel<WidgetState> {
/// Whether the widget has an error.
final bool error;

/// Whether the widget is scrolled under (e.g. content beneath an app bar).
final bool scrolledUnder;

@override
bool updateShouldNotify(WidgetStateProvider oldWidget) {
return oldWidget.disabled != disabled ||
Expand All @@ -92,7 +110,8 @@ class WidgetStateProvider extends InheritedModel<WidgetState> {
oldWidget.pressed != pressed ||
oldWidget.dragged != dragged ||
oldWidget.selected != selected ||
oldWidget.error != error;
oldWidget.error != error ||
oldWidget.scrolledUnder != scrolledUnder;
}

@override
Expand All @@ -106,7 +125,9 @@ class WidgetStateProvider extends InheritedModel<WidgetState> {
oldWidget.pressed != pressed && dependencies.hasPressed ||
oldWidget.dragged != dragged && dependencies.hasDragged ||
oldWidget.selected != selected && dependencies.hasSelected ||
oldWidget.error != error && dependencies.hasError;
oldWidget.error != error && dependencies.hasError ||
oldWidget.scrolledUnder != scrolledUnder &&
dependencies.hasScrolledUnder;
}
}

Expand Down
85 changes: 69 additions & 16 deletions packages/mix/lib/src/core/style.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

Expand Down Expand Up @@ -58,18 +60,58 @@ abstract class Style<S extends Spec<S>> extends Mix<StyleSpec<S>>
}

/// Gets the closest [Style] from the widget tree, or null if not found.
///
/// Registers an inherited-widget dependency, so a widget resolved through
/// this lookup re-resolves when the ancestor [StyleProvider] changes — even
/// if the provider's own child widget instance is identical. This matches the
/// contract of standard Flutter `of` APIs.
static Style<S>? maybeOf<S extends Spec<S>>(BuildContext context) {
final provider = context.getInheritedWidgetOfExactType<StyleProvider<S>>();
final provider = context
.dependOnInheritedWidgetOfExactType<StyleProvider<S>>();

return provider?.style;
}

bool _needsPointerTracking(Set<Style<S>> visited) {
if (!visited.add(this)) return false;

final variants = $variants;
if (variants == null) return false;

for (final variantStyle in variants) {
final variant = variantStyle.variant;
if (variant is WidgetStateVariant &&
(variant.state == .hovered || variant.state == .pressed)) {
return true;
}

if (variant is! ContextVariantBuilder &&
variantStyle.value._needsPointerTracking(visited)) {
return true;
}
}

return false;
}

/// Whether this style declares a widget-state variant that automatic pointer
/// tracking can actually produce (`hovered` or `pressed`).
///
/// [StyleBuilder] uses this to decide whether to install a
/// `MixInteractionDetector`. Other widget states (focus, disabled, selected,
/// dragged, error, scrolledUnder) require an external owner or an existing
/// `WidgetStateProvider` and are intentionally excluded — a pointer detector
/// cannot activate them, so installing one for them would be dead work.
///
/// Static nested variant branches are inspected recursively. Dynamic
/// [ContextVariantBuilder] output remains opaque and requires an external
/// state owner when it returns pointer-state variants.
@internal
Set<WidgetState> get widgetStates {
return ($variants ?? [])
.where((v) => v.variant is WidgetStateVariant)
.map((v) => (v.variant as WidgetStateVariant).state)
.toSet();
bool get needsPointerTracking {
final variants = $variants;
if (variants == null || variants.isEmpty) return false;

return _needsPointerTracking(HashSet.identity());
}

/// Merges all active variants with their nested variants recursively.
Expand All @@ -88,8 +130,12 @@ abstract class Style<S extends Spec<S>> extends Mix<StyleSpec<S>>
BuildContext context, {
required Set<NamedVariant> namedVariants,
}) {
// Filter variants that should be active in this context
final activeVariants = ($variants ?? [])
final variants = $variants;
if (variants == null || variants.isEmpty) return this;

// Filter variants that should be active in this context, preserving their
// stored order.
final activeVariants = variants
.where(
(variantAttr) => switch (variantAttr.variant) {
(ContextVariant variant) => variant.when(context),
Expand All @@ -99,18 +145,25 @@ abstract class Style<S extends Spec<S>> extends Mix<StyleSpec<S>>
)
.toList();

// Sort by priority: WidgetStateVariant gets applied last (highest priority)
activeVariants.sort(
(a, b) => Comparable.compare(
a.variant is WidgetStateVariant ? 1 : 0,
b.variant is WidgetStateVariant ? 1 : 0,
),
);
// Order by priority using a stable linear partition into two buckets,
// preserving stored order within each bucket:
// 1. non-widget-state variants (lower priority, applied first)
// 2. widget-state variants (highest priority, applied last)
//
// A partition is used instead of List.sort because Dart's sort is not
// guaranteed to preserve the relative order of items that compare equal,
// yet Mix's contract requires the last stored variant within a bucket to
// win. Deriving order by construction makes precedence deterministic
// regardless of the sort implementation.
final orderedVariants = [
...activeVariants.where((v) => v.variant is! WidgetStateVariant),
...activeVariants.where((v) => v.variant is WidgetStateVariant),
];

// Extract the style from each active variant
final stylesToMerge = <(Style<S>, bool)>[]; // (style, isFromStyleVariation)

for (final variantAttr in activeVariants) {
for (final variantAttr in orderedVariants) {
final result = switch (variantAttr.variant) {
ContextVariantBuilder variant => (
variant.build(context) as Style<S>,
Expand Down
94 changes: 33 additions & 61 deletions packages/mix/lib/src/core/style_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,44 +46,19 @@ class StyleBuilder<S extends Spec<S>> extends StatefulWidget {
State<StyleBuilder<S>> createState() => _StyleBuilderState<S>();
}

class _StyleBuilderState<S extends Spec<S>> extends State<StyleBuilder<S>>
with TickerProviderStateMixin {
late WidgetStatesController _controller;

/// Tracks whether we created the controller internally (and thus own it)
bool _ownsController = false;
class _StyleBuilderState<S extends Spec<S>> extends State<StyleBuilder<S>> {
/// A one-build state snapshot captured when an external controller is removed.
///
/// It seeds a newly mounted [MixInteractionDetector] when automatic pointer
/// tracking is needed; otherwise it is discarded to avoid restoring stale
/// state later.
Set<WidgetState>? _controllerHandoffStates;

@override
void initState() {
super.initState();
_initController();
}
Set<WidgetState> _takeControllerHandoffStates() {
final states = _controllerHandoffStates;
_controllerHandoffStates = null;

void _initController() {
if (widget.controller != null) {
_controller = widget.controller!;
_ownsController = false;
} else {
_controller = WidgetStatesController();
_ownsController = true;
}
}

void _handleControllerChange(StyleBuilder<S> oldWidget) {
// Dispose old internal controller if we owned it
if (_ownsController) {
_controller.dispose();
}

// Set up new controller
if (widget.controller != null) {
_controller = widget.controller!;
_ownsController = false;
} else {
// Create internal controller, preserving state from old external controller
_controller = WidgetStatesController(oldWidget.controller?.value ?? {});
_ownsController = true;
}
return states ?? const {};
}

Style<S> _buildStyle(BuildContext context) {
Expand All @@ -105,31 +80,19 @@ class _StyleBuilderState<S extends Spec<S>> extends State<StyleBuilder<S>>
void didUpdateWidget(covariant StyleBuilder<S> oldWidget) {
super.didUpdateWidget(oldWidget);

// Handle controller changes
if (oldWidget.controller != widget.controller) {
_handleControllerChange(oldWidget);
}
}
if (oldWidget.controller == widget.controller) return;

@override
void dispose() {
// Only dispose controllers we created internally
if (_ownsController) {
_controller.dispose();
if (widget.controller != null) {
_controllerHandoffStates = null;
} else {
_controllerHandoffStates = Set.of(oldWidget.controller!.value);
}

super.dispose();
}

@override
Widget build(BuildContext context) {
final style = _buildStyle(context);

// Calculate interactivity need early
final needsToTrackWidgetState =
widget.controller == null && style.widgetStates.isNotEmpty;

final alreadyHasWidgetStateScope = WidgetStateProvider.of(context) != null;
final externalController = widget.controller;

Widget current = Builder(
builder: (context) {
Expand All @@ -142,16 +105,25 @@ class _StyleBuilderState<S extends Spec<S>> extends State<StyleBuilder<S>>
},
);

if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) {
// If we need interactivity and no MixWidgetStateModel is present,
// wrap in MixInteractionDetector
current = MixInteractionDetector(controller: _controller, child: current);
} else if (widget.controller != null) {
// If we have an external controller, wrap with _ExternalControllerProvider
if (externalController != null) {
// An external controller drives state; mirror it into the subtree.
current = _ExternalControllerProvider(
controller: _controller,
controller: externalController,
child: current,
);
} else if (style.needsPointerTracking &&
!WidgetStateProvider.hasProvider(context)) {
// Install automatic pointer tracking only when the style reacts to a
// pointer-produced state (hover/press) and no ancestor already provides a
// WidgetStateProvider scope. A state snapshot is passed only when an
// external controller was just removed; the detector creates and owns the
// automatic controller in every case.
current = MixInteractionDetector(
initialStates: _takeControllerHandoffStates(),
child: current,
);
} else {
_controllerHandoffStates = null;
}

// If inheritable is true, wrap with StyleProvider to pass the merged style down
Expand Down
Loading
Loading