Merge pull request #208 from gskinnerTeam/accessability-trackpadfix

Accessibility - Trackpad Fix
This commit is contained in:
Alex Garneau
2025-04-07 12:08:09 -06:00
committed by GitHub
5 changed files with 277 additions and 166 deletions

View File

@ -1,4 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:wonders/common_libs.dart'; import 'package:wonders/common_libs.dart';
import 'package:wonders/ui/common/controls/trackpad_listener.dart';
class EightWaySwipeDetector extends StatefulWidget { class EightWaySwipeDetector extends StatefulWidget {
const EightWaySwipeDetector({super.key, required this.child, this.threshold = 50, required this.onSwipe}); const EightWaySwipeDetector({super.key, required this.child, this.threshold = 50, required this.onSwipe});
@ -40,12 +42,16 @@ class _EightWaySwipeDetectorState extends State<EightWaySwipeDetector> {
} }
} }
void _handleSwipeStart(d) { void _trackpadSwipe(Offset delta) {
_isSwiping = true; widget.onSwipe?.call(delta);
}
void _handleSwipeStart(DragStartDetails d) {
_isSwiping = d.kind != null;
_startPos = _endPos = d.localPosition; _startPos = _endPos = d.localPosition;
} }
void _handleSwipeUpdate(d) { void _handleSwipeUpdate(DragUpdateDetails d) {
_endPos = d.localPosition; _endPos = d.localPosition;
_maybeTriggerSwipe(); _maybeTriggerSwipe();
} }
@ -57,12 +63,24 @@ class _EightWaySwipeDetectorState extends State<EightWaySwipeDetector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return TrackpadListener(
scrollSensitivity: 70,
onScroll: _trackpadSwipe,
child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onPanStart: _handleSwipeStart, onPanStart: _handleSwipeStart,
onPanUpdate: _handleSwipeUpdate, onPanUpdate: _handleSwipeUpdate,
onPanCancel: _resetSwipe, onPanCancel: _resetSwipe,
onPanEnd: _handleSwipeEnd, onPanEnd: _handleSwipeEnd,
child: widget.child); supportedDevices: const {
// Purposely omitting PointerDeviceKind.trackpad.
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.unknown,
},
child: widget.child,
),
);
} }
} }

View File

@ -0,0 +1,69 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class TrackpadListener extends StatefulWidget {
final Widget? child;
final double scrollSensitivity;
final ValueChanged<Offset>? onScroll;
const TrackpadListener({
super.key,
this.child,
this.scrollSensitivity = 100,
this.onScroll,
});
@override
State<TrackpadListener> createState() => _TrackpadListenerState();
}
class _TrackpadListenerState extends State<TrackpadListener> {
Offset _scrollOffset = Offset.zero;
void _handleTrackpadEvent(PointerSignalEvent event) {
// Directly process the event here.
if (event is PointerScrollEvent && event.kind == PointerDeviceKind.trackpad) {
Offset newScroll = _scrollOffset + event.scrollDelta;
newScroll = Offset(
newScroll.dx.clamp(-widget.scrollSensitivity, widget.scrollSensitivity),
newScroll.dy.clamp(-widget.scrollSensitivity, widget.scrollSensitivity),
);
_scrollOffset = newScroll;
_update();
}
}
void _update() {
Offset directionScroll = Offset.zero;
double sensitivity = widget.scrollSensitivity;
if (_scrollOffset.dy >= sensitivity) {
// Scroll down
_scrollOffset += Offset(0.0, -sensitivity);
directionScroll += Offset(0.0, -1.0);
} else if (_scrollOffset.dy <= -sensitivity) {
// Scroll up
_scrollOffset += Offset(0.0, sensitivity);
directionScroll += Offset(0.0, 1.0);
}
if (_scrollOffset.dx >= sensitivity) {
// Scroll left
_scrollOffset += Offset(-sensitivity, 0.0);
directionScroll += Offset(-1.0, 0.0);
} else if (_scrollOffset.dx <= -sensitivity) {
// Scroll right
_scrollOffset += Offset(sensitivity, 0.0);
directionScroll += Offset(1.0, 0.0);
}
if (directionScroll != Offset.zero) {
widget.onScroll?.call(directionScroll);
}
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: _handleTrackpadEvent,
child: widget.child,
);
}
}

View File

@ -13,6 +13,7 @@ import 'package:wonders/ui/common/app_icons.dart';
import 'package:wonders/ui/common/blend_mask.dart'; import 'package:wonders/ui/common/blend_mask.dart';
import 'package:wonders/ui/common/centered_box.dart'; import 'package:wonders/ui/common/centered_box.dart';
import 'package:wonders/ui/common/compass_divider.dart'; import 'package:wonders/ui/common/compass_divider.dart';
import 'package:wonders/ui/common/controls/trackpad_listener.dart';
import 'package:wonders/ui/common/curved_clippers.dart'; import 'package:wonders/ui/common/curved_clippers.dart';
import 'package:wonders/ui/common/fullscreen_keyboard_list_scroller.dart'; import 'package:wonders/ui/common/fullscreen_keyboard_list_scroller.dart';
import 'package:wonders/ui/common/google_maps_marker.dart'; import 'package:wonders/ui/common/google_maps_marker.dart';
@ -66,6 +67,13 @@ class _WonderEditorialScreenState extends State<WonderEditorialScreen> {
_scrollPos.value = _scroller.position.pixels; _scrollPos.value = _scroller.position.pixels;
} }
void _handleTrackpadScroll(Offset direction) {
// Trackpad swipe up. Return to home if at the top.
if (_scroller.position.pixels == 0 && direction.dy > 0) {
_handleBackPressed();
}
}
void _handleBackPressed() => context.go(ScreenPaths.home); void _handleBackPressed() => context.go(ScreenPaths.home);
@override @override
@ -82,115 +90,119 @@ class _WonderEditorialScreenState extends State<WonderEditorialScreen> {
controller: _scroller, controller: _scroller,
child: ColoredBox( child: ColoredBox(
color: $styles.colors.offWhite, color: $styles.colors.offWhite,
child: Stack( child: TrackpadListener(
children: [ onScroll: _handleTrackpadScroll,
/// Background scrollSensitivity: 120,
Positioned.fill( child: Stack(
child: ColoredBox(color: widget.data.type.bgColor), children: [
), /// Background
Positioned.fill(
/// Top Illustration - Sits underneath the scrolling content, fades out as it scrolls child: ColoredBox(color: widget.data.type.bgColor),
SizedBox(
height: illustrationHeight,
child: ValueListenableBuilder<double>(
valueListenable: _scrollPos,
builder: (_, value, child) {
// get some value between 0 and 1, based on the amt scrolled
double opacity = (1 - value / 700).clamp(0, 1);
return Opacity(opacity: opacity, child: child);
},
// This is due to a bug: https://github.com/flutter/flutter/issues/101872
child: RepaintBoundary(
child: _TopIllustration(
widget.data.type,
// Polish: Inject the content padding into the illustration as an offset, so it can center itself relative to the content
// this allows the background to extend underneath the vertical side nav when it has rounded corners.
fgOffset: Offset(widget.contentPadding.left / 2, 0),
)),
), ),
),
/// Scrolling content - Includes an invisible gap at the top, and then scrolls over the illustration /// Top Illustration - Sits underneath the scrolling content, fades out as it scrolls
TopCenter( SizedBox(
child: Padding( height: illustrationHeight,
padding: widget.contentPadding, child: ValueListenableBuilder<double>(
child: SizedBox( valueListenable: _scrollPos,
child: FocusTraversalGroup( builder: (_, value, child) {
child: FullscreenKeyboardListScroller( // get some value between 0 and 1, based on the amt scrolled
scrollController: _scroller, double opacity = (1 - value / 700).clamp(0, 1);
child: CustomScrollView( return Opacity(opacity: opacity, child: child);
controller: _scroller, },
scrollBehavior: ScrollConfiguration.of(context).copyWith(), // This is due to a bug: https://github.com/flutter/flutter/issues/101872
key: PageStorageKey('editorial'), child: RepaintBoundary(
slivers: [ child: _TopIllustration(
/// Invisible padding at the top of the list, so the illustration shows through the btm widget.data.type,
SliverToBoxAdapter( // Polish: Inject the content padding into the illustration as an offset, so it can center itself relative to the content
child: SizedBox(height: illustrationHeight), // this allows the background to extend underneath the vertical side nav when it has rounded corners.
), fgOffset: Offset(widget.contentPadding.left / 2, 0),
)),
),
),
/// Text content, animates itself to hide behind the app bar as it scrolls up /// Scrolling content - Includes an invisible gap at the top, and then scrolls over the illustration
SliverToBoxAdapter( TopCenter(
child: ValueListenableBuilder<double>( child: Padding(
valueListenable: _scrollPos, padding: widget.contentPadding,
builder: (_, value, child) { child: SizedBox(
double offsetAmt = max(0, value * .3); child: FocusTraversalGroup(
double opacity = (1 - offsetAmt / 150).clamp(0, 1); child: FullscreenKeyboardListScroller(
return Transform.translate( scrollController: _scroller,
offset: Offset(0, offsetAmt), child: CustomScrollView(
child: Opacity(opacity: opacity, child: child), controller: _scroller,
); scrollBehavior: ScrollConfiguration.of(context).copyWith(),
}, key: PageStorageKey('editorial'),
child: _TitleText(widget.data, scroller: _scroller), slivers: [
/// Invisible padding at the top of the list, so the illustration shows through the btm
SliverToBoxAdapter(
child: SizedBox(height: illustrationHeight),
), ),
),
/// Collapsing App bar, pins to the top of the list /// Text content, animates itself to hide behind the app bar as it scrolls up
SliverAppBar( SliverToBoxAdapter(
pinned: true, child: ValueListenableBuilder<double>(
collapsedHeight: minAppBarHeight, valueListenable: _scrollPos,
toolbarHeight: minAppBarHeight, builder: (_, value, child) {
expandedHeight: maxAppBarHeight, double offsetAmt = max(0, value * .3);
backgroundColor: Colors.transparent, double opacity = (1 - offsetAmt / 150).clamp(0, 1);
elevation: 0, return Transform.translate(
leading: SizedBox.shrink(), offset: Offset(0, offsetAmt),
flexibleSpace: SizedBox.expand( child: Opacity(opacity: opacity, child: child),
child: _AppBar( );
widget.data.type, },
scrollPos: _scrollPos, child: _TitleText(widget.data, scroller: _scroller),
sectionIndex: _sectionIndex,
), ),
), ),
),
/// Editorial content (text and images) /// Collapsing App bar, pins to the top of the list
_ScrollingContent(widget.data, scrollPos: _scrollPos, sectionNotifier: _sectionIndex), SliverAppBar(
], pinned: true,
collapsedHeight: minAppBarHeight,
toolbarHeight: minAppBarHeight,
expandedHeight: maxAppBarHeight,
backgroundColor: Colors.transparent,
elevation: 0,
leading: SizedBox.shrink(),
flexibleSpace: SizedBox.expand(
child: _AppBar(
widget.data.type,
scrollPos: _scrollPos,
sectionIndex: _sectionIndex,
),
),
),
/// Editorial content (text and images)
_ScrollingContent(widget.data, scrollPos: _scrollPos, sectionNotifier: _sectionIndex),
],
),
), ),
), ),
), ),
), ),
), ),
),
/// Home Btn /// Home Btn
AnimatedBuilder( AnimatedBuilder(
animation: _scroller, animation: _scroller,
builder: (_, child) { builder: (_, child) {
return AnimatedOpacity( return AnimatedOpacity(
opacity: _scrollPos.value > 0 ? 0 : 1, opacity: _scrollPos.value > 0 ? 0 : 1,
duration: $styles.times.med, duration: $styles.times.med,
child: child, child: child,
); );
}, },
child: Align( child: Align(
alignment: backBtnAlign, alignment: backBtnAlign,
child: Padding( child: Padding(
padding: EdgeInsets.all($styles.insets.sm), padding: EdgeInsets.all($styles.insets.sm),
child: BackBtn(icon: AppIcons.north, onPressed: _handleBackPressed), child: BackBtn(icon: AppIcons.north, onPressed: _handleBackPressed),
),
), ),
), )
) ],
], ),
), ),
), ),
); );

View File

@ -4,6 +4,7 @@ import 'package:wonders/logic/data/wonder_data.dart';
import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/app_icons.dart';
import 'package:wonders/ui/common/controls/app_header.dart'; import 'package:wonders/ui/common/controls/app_header.dart';
import 'package:wonders/ui/common/controls/app_page_indicator.dart'; import 'package:wonders/ui/common/controls/app_page_indicator.dart';
import 'package:wonders/ui/common/controls/trackpad_listener.dart';
import 'package:wonders/ui/common/gradient_container.dart'; import 'package:wonders/ui/common/gradient_container.dart';
import 'package:wonders/ui/common/ignore_pointer.dart'; import 'package:wonders/ui/common/ignore_pointer.dart';
import 'package:wonders/ui/common/previous_next_navigation.dart'; import 'package:wonders/ui/common/previous_next_navigation.dart';
@ -133,6 +134,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
} }
} }
void _handleTrackpadScroll(Offset direction) {
if (direction.dy < 0) {
_showDetailsPage();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_fadeInOnNextBuild == true) { if (_fadeInOnNextBuild == true) {
@ -142,25 +149,29 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return _swipeController.wrapGestureDetector(Container( return _swipeController.wrapGestureDetector(Container(
color: $styles.colors.black, color: $styles.colors.black,
child: PreviousNextNavigation( child: TrackpadListener(
listenToMouseWheel: false, scrollSensitivity: 60,
onPreviousPressed: () => _handlePrevNext(-1), onScroll: _handleTrackpadScroll,
onNextPressed: () => _handlePrevNext(1), child: PreviousNextNavigation(
child: Stack( listenToMouseWheel: false,
children: [ onPreviousPressed: () => _handlePrevNext(-1),
/// Background onNextPressed: () => _handlePrevNext(1),
..._buildBgAndClouds(), child: Stack(
children: [
/// Background
..._buildBgAndClouds(),
/// Wonders Illustrations (main content) /// Wonders Illustrations (main content)
_buildMgPageView(), _buildMgPageView(),
/// Foreground illustrations and gradients /// Foreground illustrations and gradients
_buildFgAndGradients(), _buildFgAndGradients(),
/// Controls that float on top of the various illustrations /// Controls that float on top of the various illustrations
_buildFloatingUi(), _buildFloatingUi(),
], ],
).maybeAnimate().fadeIn(), ).maybeAnimate().fadeIn(),
),
), ),
)); ));
} }

View File

@ -173,62 +173,63 @@ class _PhotoGalleryState extends State<PhotoGallery> {
return FullscreenKeyboardListener( return FullscreenKeyboardListener(
onKeyDown: _handleKeyDown, onKeyDown: _handleKeyDown,
child: ValueListenableBuilder<List<String>>( child: ValueListenableBuilder<List<String>>(
valueListenable: _photoIds, valueListenable: _photoIds,
builder: (_, value, __) { builder: (_, value, __) {
if (value.isEmpty) { if (value.isEmpty) {
return Center(child: AppLoadingIndicator()); return Center(child: AppLoadingIndicator());
} }
Size imgSize = context.isLandscape Size imgSize = context.isLandscape
? Size(context.widthPx * .5, context.heightPx * .66) ? Size(context.widthPx * .5, context.heightPx * .66)
: Size(context.widthPx * .66, context.heightPx * .5); : Size(context.widthPx * .66, context.heightPx * .5);
imgSize = (widget.imageSize ?? imgSize) * _scale; imgSize = (widget.imageSize ?? imgSize) * _scale;
// Get transform offset for the current _index // Get transform offset for the current _index
final padding = $styles.insets.md; final padding = $styles.insets.md;
var gridOffset = _calculateCurrentOffset(padding, imgSize); var gridOffset = _calculateCurrentOffset(padding, imgSize);
gridOffset += Offset(0, -context.mq.padding.top / 2); gridOffset += Offset(0, -context.mq.padding.top / 2);
final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration;
final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5;
return _AnimatedCutoutOverlay( return _AnimatedCutoutOverlay(
animationKey: ValueKey(_index), animationKey: ValueKey(_index),
cutoutSize: imgSize, cutoutSize: imgSize,
swipeDir: _lastSwipeDir, swipeDir: _lastSwipeDir,
duration: cutoutTweenDuration, duration: cutoutTweenDuration,
opacity: _scale == 1 ? .7 : .5, opacity: _scale == 1 ? .7 : .5,
enabled: useClipPathWorkAroundForWeb == false, enabled: useClipPathWorkAroundForWeb == false,
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
// Place content in overflow box, to allow it to flow outside the parent // Place content in overflow box, to allow it to flow outside the parent
child: OverflowBox( child: OverflowBox(
maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1),
maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1),
alignment: Alignment.center, alignment: Alignment.center,
// Detect swipes in order to change index // Detect swipes in order to change index
child: EightWaySwipeDetector( child: EightWaySwipeDetector(
onSwipe: _handleSwipe, onSwipe: _handleSwipe,
threshold: 30, threshold: 30,
// A tween animation builder moves from image to image based on current offset // A tween animation builder moves from image to image based on current offset
child: TweenAnimationBuilder<Offset>( child: TweenAnimationBuilder<Offset>(
tween: Tween(begin: gridOffset, end: gridOffset), tween: Tween(begin: gridOffset, end: gridOffset),
duration: offsetTweenDuration, duration: offsetTweenDuration,
curve: Curves.easeOut, curve: Curves.easeOut,
builder: (_, value, child) => Transform.translate(offset: value, child: child), builder: (_, value, child) => Transform.translate(offset: value, child: child),
child: FocusTraversalGroup( child: FocusTraversalGroup(
//policy: OrderedTraversalPolicy(), //policy: OrderedTraversalPolicy(),
child: GridView.count( child: GridView.count(
physics: NeverScrollableScrollPhysics(), physics: NeverScrollableScrollPhysics(),
crossAxisCount: _gridSize, crossAxisCount: _gridSize,
childAspectRatio: imgSize.aspectRatio, childAspectRatio: imgSize.aspectRatio,
mainAxisSpacing: padding, mainAxisSpacing: padding,
crossAxisSpacing: padding, crossAxisSpacing: padding,
children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)),
),
), ),
), ),
), ),
), ),
), ),
); ),
}), );
},
),
); );
} }