Skip to content
Draft
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
31 changes: 31 additions & 0 deletions packages/mix/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@
discovery instead of relying on the framework recognizing a specific variant
type. Automatic self-tracking is limited to pointer-driven hover and press;
other states still require an ancestor scope or external controller.
- **Typed focus-visible variants:** Added `FocusVisibleVariant`,
`ContextVariant.focusVisible()`, and `onFocusVisible(...)`, which apply while
focus is highlighted in Flutter's traditional (keyboard/directional) mode.
- **Pressable semantics roles:** Added `PressableSemanticsRole` with button,
link, and neutral roles. `PressableBox` now forwards the full Pressable
focus, keyboard, controller, feedback, cursor, action, and semantics surface.

### Breaking changes

- **Pressable input and semantics:** Replaced `semanticButtonLabel` with
`semanticsLabel`, added `semanticsRole`, and removed the deprecated `onKey`
callback. Use `onKeyEvent` for custom keyboard handling.
- **Reserved activation keys:** While it holds primary focus and can activate,
Pressable owns unmodified Space, Enter, numpad Enter, select, and game button
A so it can model held-key state consistently. Override those direct key
bindings with `onKeyEvent`; custom `actions` remain available to other
shortcuts and programmatic intents. Those keys are left untouched when a
descendant holds focus, and modified chords are left to application
shortcuts.

### Fixes

Expand All @@ -22,6 +41,18 @@
so styles depending solely on those no longer gain an opaque hit-test target
that swallowed pointer events aimed at widgets beneath them, and no longer
hijack the state scope of descendants that do track hover.
- **Pressable lifecycle:** Pointer and keyboard press sources are combined
without clearing each other, keyboard activation fires once on key-up,
cancellation clears held state, focus-visible follows Flutter input modality,
and disabled controls ignore custom key handling and expose neither semantic
nor custom actions.
- **Press state ends with the gesture:** A pointer that drifts past the tap slop
stops counting as a press, so items no longer stay visually pressed while a
list scrolls under the finger.
- **Focus-visible scope:** The focus-highlight scope is now provided wherever
widget states are, so `onFocusVisible` also resolves — and repaints on input
modality changes — outside a `Pressable`. A `WidgetStateStyleOverride` forcing
`focused` now applies it too, matching `onFocused`.

## 2.2.0-beta.1

Expand Down
146 changes: 107 additions & 39 deletions packages/mix/lib/src/core/internal/mix_interaction_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

import '../pointer_position.dart';
import '../providers/focus_highlight_mode_provider.dart';
import '../providers/widget_state_provider.dart';

/// A widget that detects user interactions and provides state tracking with automatic mouse position tracking.
Expand All @@ -20,6 +21,8 @@ class MixInteractionDetector extends StatefulWidget {
this.controller,
this.enabled = true,
this.onHoverChange,
this.onPressChange,
this.managesPressedState = true,
this.onPointerPositionChange,
});

Expand All @@ -40,6 +43,14 @@ class MixInteractionDetector extends StatefulWidget {
final WidgetStatesController? controller;
final bool enabled;
final ValueChanged<bool>? onHoverChange;
final ValueChanged<bool>? onPressChange;

/// Whether pointer input is written directly to [controller].
///
/// Set this to false when the owner combines pointer presses with another
/// input source before publishing [WidgetState.pressed].
final bool managesPressedState;

final ValueChanged<PointerPosition>? onPointerPositionChange;

@override
Expand All @@ -50,6 +61,18 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
WidgetStatesController? _internalController;
late final PointerPositionNotifier _cursorPositionNotifier;

/// Global position of the pointer that owns the current pressed state.
Offset? _pressOrigin;

/// Pointer that owns the current pressed state.
int? _pressPointer;

/// Distance a pointer may drift before it stops counting as a press.
///
/// Mirrors what [TapGestureRecognizer] uses, so the pressed state and the tap
/// gesture give up on the same movement.
double _touchSlop = kTouchSlop;

@override
void initState() {
super.initState();
Expand All @@ -67,7 +90,7 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
_effectiveController.update(.disabled, !widget.enabled);
if (!widget.enabled) {
_effectiveController.update(.hovered, false);
_effectiveController.update(.pressed, false);
_clearPressedState(force: true);
_cursorPositionNotifier.clearPosition();
widget.onHoverChange?.call(false);
}
Expand All @@ -85,10 +108,22 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
}
}

/// Clears the pressed state and notifies listeners.
void _clearPressedState() {
if (!_effectiveController.value.contains(WidgetState.pressed)) return;
_effectiveController.update(.pressed, false);
/// Clears the pressed state if [pointer] owns it.
void _clearPressedState({int? pointer, bool force = false}) {
final pressPointer = _pressPointer;
if (!force &&
(pressPointer == null ||
(pointer != null && pointer != pressPointer))) {
return;
}

final hadPointerPress = pressPointer != null;
_pressPointer = null;
_pressOrigin = null;
if (widget.managesPressedState) {
_effectiveController.update(.pressed, false);
}
if (hadPointerPress) widget.onPressChange?.call(false);
}

/// Handles pointer entering the widget bounds.
Expand All @@ -108,43 +143,60 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
widget.onHoverChange?.call(false);

// Clear pressed state if active (edge case handling)
_clearPressedState();
_clearPressedState(pointer: event.pointer);
}

/// Handles pointer down events for all pointer types.
void _handlePointerDown(PointerDownEvent event) {
if (!mounted) return;
// Only treat primary mouse button as "pressed" for mouse; all other kinds count.
if (event.kind == .mouse && (event.buttons & kPrimaryMouseButton) == 0) {
return;

// Match GestureDetector's primary tap recognizer across device kinds.
if (_pressPointer != null || event.buttons != kPrimaryButton) return;

_pressPointer = event.pointer;
_pressOrigin = event.position;
if (widget.managesPressedState) {
_effectiveController.update(.pressed, true);
}
_effectiveController.update(.pressed, true);
widget.onPressChange?.call(true);
}

/// Handles pointer up events.
void _handlePointerUp(PointerUpEvent event) {
if (!mounted) return;
_effectiveController.update(.pressed, false);
_clearPressedState(pointer: event.pointer);
}

/// Handles pointer cancel events.
void _handlePointerCancel(PointerCancelEvent event) {
if (!mounted) return;
_effectiveController.update(.pressed, false);
_clearPressedState(pointer: event.pointer);
}

/// Handles pointer move events to track boundary crossings.
void _handlePointerMove(PointerMoveEvent event) {
if (!mounted) return;

if (event.pointer != _pressPointer) return;

// A pointer that drifts past the tap slop has become a drag or a scroll,
// so it no longer owns a press. This [Listener] sees raw pointer events
// rather than arena outcomes, and a scrolled widget travels with the
// pointer, so bounds alone never notice.
final pressOrigin = _pressOrigin;
if (pressOrigin != null &&
(event.position - pressOrigin).distance > _touchSlop) {
_clearPressedState(pointer: event.pointer);

return;
}

final size = context.size;
if (size == null) return;

final isInside = size.contains(event.localPosition);

// Clear pressed state when moving outside
if (!isInside) {
_clearPressedState();
if (!size.contains(event.localPosition)) {
_clearPressedState(pointer: event.pointer);
}
}

Expand Down Expand Up @@ -181,6 +233,13 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
widget.controller ??
(_internalController ??= _createInternalController());

@override
void didChangeDependencies() {
super.didChangeDependencies();
_touchSlop =
MediaQuery.maybeGestureSettingsOf(context)?.touchSlop ?? kTouchSlop;
}

@override
void didUpdateWidget(MixInteractionDetector oldWidget) {
super.didUpdateWidget(oldWidget);
Expand All @@ -189,6 +248,9 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {
if (oldWidget.controller != widget.controller) {
_handleControllerChange(oldWidget);
_syncDisabledState();
if (widget.managesPressedState && _pressPointer != null) {
_effectiveController.update(.pressed, true);
}
}

// Handle enabled state changes
Expand All @@ -206,29 +268,35 @@ class _MixInteractionDetectorState extends State<MixInteractionDetector> {

@override
Widget build(BuildContext context) {
// Build order: IgnorePointer -> MouseRegion -> Listener -> PointerPositionProvider -> ListenableBuilder -> WidgetStateProvider
return IgnorePointer(
ignoring: !widget.enabled,
child: MouseRegion(
onEnter: _handlePointerEnter,
onExit: _handlePointerExit,
onHover: _handleOnPointerHover,
child: Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerUp,
onPointerCancel: _handlePointerCancel,
behavior: .opaque,
child: PointerPositionProvider(
notifier: _cursorPositionNotifier,
child: ListenableBuilder(
listenable: _effectiveController,
builder: (context, _) {
return WidgetStateProvider(
states: _effectiveController.value,
child: widget.child,
);
},
// Build order: FocusHighlightModeProvider -> IgnorePointer -> MouseRegion -> Listener -> PointerPositionProvider -> ListenableBuilder -> WidgetStateProvider
//
// The focus-highlight scope is paired with the widget-state scope: any
// subtree that can resolve widget-state variants can also resolve the
// focus-visible variant, which needs both signals.
return FocusHighlightModeProvider(
child: IgnorePointer(
ignoring: !widget.enabled,
child: MouseRegion(
onEnter: _handlePointerEnter,
onExit: _handlePointerExit,
onHover: _handleOnPointerHover,
child: Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerUp,
onPointerCancel: _handlePointerCancel,
behavior: .opaque,
child: PointerPositionProvider(
notifier: _cursorPositionNotifier,
child: ListenableBuilder(
listenable: _effectiveController,
builder: (context, _) {
return WidgetStateProvider(
states: _effectiveController.value,
child: widget.child,
);
},
),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

/// Provides the current Flutter focus-highlight mode to descendants.
@internal
class FocusHighlightModeProvider extends StatefulWidget {
const FocusHighlightModeProvider({super.key, required this.child});

static FocusHighlightMode of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_FocusHighlightModeScope>()
?.mode ??
FocusManager.instance.highlightMode;
}

final Widget child;

@override
State<FocusHighlightModeProvider> createState() =>
_FocusHighlightModeProviderState();
}

class _FocusHighlightModeProviderState
extends State<FocusHighlightModeProvider> {
late FocusHighlightMode _mode;

@override
void initState() {
super.initState();
_mode = FocusManager.instance.highlightMode;
FocusManager.instance.addHighlightModeListener(_handleModeChange);
}

void _handleModeChange(FocusHighlightMode mode) {
if (!mounted || mode == _mode) return;

setState(() => _mode = mode);
}

@override
void dispose() {
FocusManager.instance.removeHighlightModeListener(_handleModeChange);
super.dispose();
}

@override
Widget build(BuildContext context) {
return _FocusHighlightModeScope(mode: _mode, child: widget.child);
}
}

class _FocusHighlightModeScope extends InheritedWidget {
const _FocusHighlightModeScope({required this.mode, required super.child});

final FocusHighlightMode mode;

@override
bool updateShouldNotify(_FocusHighlightModeScope oldWidget) {
return mode != oldWidget.mode;
}
}
15 changes: 10 additions & 5 deletions packages/mix/lib/src/core/style_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import '../animation/style_animation_builder.dart';
import '../modifiers/internal/render_modifier.dart';
import 'internal/mix_interaction_detector.dart';
import 'providers/focus_highlight_mode_provider.dart';
import 'providers/style_provider.dart';
import 'providers/style_spec_provider.dart';
import 'providers/widget_state_provider.dart';
Expand Down Expand Up @@ -227,11 +228,15 @@ class _ExternalControllerProvider extends StatelessWidget {

@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (_, _) {
return WidgetStateProvider(states: controller.value, child: child);
},
// Paired with the widget-state scope so the focus-visible variant can read
// the input modality wherever focused state is published.
return FocusHighlightModeProvider(
child: ListenableBuilder(
listenable: controller,
builder: (_, _) {
return WidgetStateProvider(states: controller.value, child: child);
},
),
);
}
}
Loading
Loading