diff --git a/lib/ui/common/controls/eight_way_swipe_detector.dart b/lib/ui/common/controls/eight_way_swipe_detector.dart index f4a65257..2017c4ab 100644 --- a/lib/ui/common/controls/eight_way_swipe_detector.dart +++ b/lib/ui/common/controls/eight_way_swipe_detector.dart @@ -1,4 +1,6 @@ +import 'package:flutter/gestures.dart'; import 'package:wonders/common_libs.dart'; +import 'package:wonders/ui/common/controls/trackpad_listener.dart'; class EightWaySwipeDetector extends StatefulWidget { const EightWaySwipeDetector({super.key, required this.child, this.threshold = 50, required this.onSwipe}); @@ -40,12 +42,16 @@ class _EightWaySwipeDetectorState extends State { } } - void _handleSwipeStart(d) { - _isSwiping = true; + void _trackpadSwipe(Offset delta) { + widget.onSwipe?.call(delta); + } + + void _handleSwipeStart(DragStartDetails d) { + _isSwiping = d.kind != null; _startPos = _endPos = d.localPosition; } - void _handleSwipeUpdate(d) { + void _handleSwipeUpdate(DragUpdateDetails d) { _endPos = d.localPosition; _maybeTriggerSwipe(); } @@ -57,12 +63,24 @@ class _EightWaySwipeDetectorState extends State { @override Widget build(BuildContext context) { - return GestureDetector( + return TrackpadListener( + scrollSensitivity: 70, + onScroll: _trackpadSwipe, + child: GestureDetector( behavior: HitTestBehavior.translucent, onPanStart: _handleSwipeStart, onPanUpdate: _handleSwipeUpdate, onPanCancel: _resetSwipe, onPanEnd: _handleSwipeEnd, - child: widget.child); + supportedDevices: const { + // Purposely omitting PointerDeviceKind.trackpad. + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.touch, + PointerDeviceKind.unknown, + }, + child: widget.child, + ), + ); } } diff --git a/lib/ui/common/controls/trackpad_listener.dart b/lib/ui/common/controls/trackpad_listener.dart new file mode 100644 index 00000000..8dab79be --- /dev/null +++ b/lib/ui/common/controls/trackpad_listener.dart @@ -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? onScroll; + + const TrackpadListener({ + super.key, + this.child, + this.scrollSensitivity = 100, + this.onScroll, + }); + + @override + State createState() => _TrackpadListenerState(); +} + +class _TrackpadListenerState extends State { + 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, + ); + } +} diff --git a/lib/ui/screens/editorial/editorial_screen.dart b/lib/ui/screens/editorial/editorial_screen.dart index c6eb00f7..721be97a 100644 --- a/lib/ui/screens/editorial/editorial_screen.dart +++ b/lib/ui/screens/editorial/editorial_screen.dart @@ -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/centered_box.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/fullscreen_keyboard_list_scroller.dart'; import 'package:wonders/ui/common/google_maps_marker.dart'; @@ -66,6 +67,13 @@ class _WonderEditorialScreenState extends State { _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); @override @@ -82,115 +90,119 @@ class _WonderEditorialScreenState extends State { controller: _scroller, child: ColoredBox( color: $styles.colors.offWhite, - child: Stack( - children: [ - /// Background - Positioned.fill( - child: ColoredBox(color: widget.data.type.bgColor), - ), - - /// Top Illustration - Sits underneath the scrolling content, fades out as it scrolls - SizedBox( - height: illustrationHeight, - child: ValueListenableBuilder( - 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), - )), + child: TrackpadListener( + onScroll: _handleTrackpadScroll, + scrollSensitivity: 120, + child: Stack( + children: [ + /// Background + Positioned.fill( + child: ColoredBox(color: widget.data.type.bgColor), ), - ), - /// Scrolling content - Includes an invisible gap at the top, and then scrolls over the illustration - TopCenter( - child: Padding( - padding: widget.contentPadding, - child: SizedBox( - child: FocusTraversalGroup( - child: FullscreenKeyboardListScroller( - scrollController: _scroller, - child: CustomScrollView( - controller: _scroller, - scrollBehavior: ScrollConfiguration.of(context).copyWith(), - key: PageStorageKey('editorial'), - slivers: [ - /// Invisible padding at the top of the list, so the illustration shows through the btm - SliverToBoxAdapter( - child: SizedBox(height: illustrationHeight), - ), + /// Top Illustration - Sits underneath the scrolling content, fades out as it scrolls + SizedBox( + height: illustrationHeight, + child: ValueListenableBuilder( + 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), + )), + ), + ), - /// Text content, animates itself to hide behind the app bar as it scrolls up - SliverToBoxAdapter( - child: ValueListenableBuilder( - valueListenable: _scrollPos, - builder: (_, value, child) { - double offsetAmt = max(0, value * .3); - double opacity = (1 - offsetAmt / 150).clamp(0, 1); - return Transform.translate( - offset: Offset(0, offsetAmt), - child: Opacity(opacity: opacity, child: child), - ); - }, - child: _TitleText(widget.data, scroller: _scroller), + /// Scrolling content - Includes an invisible gap at the top, and then scrolls over the illustration + TopCenter( + child: Padding( + padding: widget.contentPadding, + child: SizedBox( + child: FocusTraversalGroup( + child: FullscreenKeyboardListScroller( + scrollController: _scroller, + child: CustomScrollView( + controller: _scroller, + scrollBehavior: ScrollConfiguration.of(context).copyWith(), + key: PageStorageKey('editorial'), + 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 - 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, + /// Text content, animates itself to hide behind the app bar as it scrolls up + SliverToBoxAdapter( + child: ValueListenableBuilder( + valueListenable: _scrollPos, + builder: (_, value, child) { + double offsetAmt = max(0, value * .3); + double opacity = (1 - offsetAmt / 150).clamp(0, 1); + return Transform.translate( + offset: Offset(0, offsetAmt), + child: Opacity(opacity: opacity, child: child), + ); + }, + child: _TitleText(widget.data, scroller: _scroller), ), ), - ), - /// Editorial content (text and images) - _ScrollingContent(widget.data, scrollPos: _scrollPos, sectionNotifier: _sectionIndex), - ], + /// Collapsing App bar, pins to the top of the list + 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 - AnimatedBuilder( - animation: _scroller, - builder: (_, child) { - return AnimatedOpacity( - opacity: _scrollPos.value > 0 ? 0 : 1, - duration: $styles.times.med, - child: child, - ); - }, - child: Align( - alignment: backBtnAlign, - child: Padding( - padding: EdgeInsets.all($styles.insets.sm), - child: BackBtn(icon: AppIcons.north, onPressed: _handleBackPressed), + /// Home Btn + AnimatedBuilder( + animation: _scroller, + builder: (_, child) { + return AnimatedOpacity( + opacity: _scrollPos.value > 0 ? 0 : 1, + duration: $styles.times.med, + child: child, + ); + }, + child: Align( + alignment: backBtnAlign, + child: Padding( + padding: EdgeInsets.all($styles.insets.sm), + child: BackBtn(icon: AppIcons.north, onPressed: _handleBackPressed), + ), ), - ), - ) - ], + ) + ], + ), ), ), ); diff --git a/lib/ui/screens/home/wonders_home_screen.dart b/lib/ui/screens/home/wonders_home_screen.dart index 1ab8309e..105c65aa 100644 --- a/lib/ui/screens/home/wonders_home_screen.dart +++ b/lib/ui/screens/home/wonders_home_screen.dart @@ -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/controls/app_header.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/ignore_pointer.dart'; import 'package:wonders/ui/common/previous_next_navigation.dart'; @@ -133,6 +134,12 @@ class _HomeScreenState extends State with SingleTickerProviderStateM } } + void _handleTrackpadScroll(Offset direction) { + if (direction.dy < 0) { + _showDetailsPage(); + } + } + @override Widget build(BuildContext context) { if (_fadeInOnNextBuild == true) { @@ -142,25 +149,29 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return _swipeController.wrapGestureDetector(Container( color: $styles.colors.black, - child: PreviousNextNavigation( - listenToMouseWheel: false, - onPreviousPressed: () => _handlePrevNext(-1), - onNextPressed: () => _handlePrevNext(1), - child: Stack( - children: [ - /// Background - ..._buildBgAndClouds(), + child: TrackpadListener( + scrollSensitivity: 60, + onScroll: _handleTrackpadScroll, + child: PreviousNextNavigation( + listenToMouseWheel: false, + onPreviousPressed: () => _handlePrevNext(-1), + onNextPressed: () => _handlePrevNext(1), + child: Stack( + children: [ + /// Background + ..._buildBgAndClouds(), - /// Wonders Illustrations (main content) - _buildMgPageView(), + /// Wonders Illustrations (main content) + _buildMgPageView(), - /// Foreground illustrations and gradients - _buildFgAndGradients(), + /// Foreground illustrations and gradients + _buildFgAndGradients(), - /// Controls that float on top of the various illustrations - _buildFloatingUi(), - ], - ).maybeAnimate().fadeIn(), + /// Controls that float on top of the various illustrations + _buildFloatingUi(), + ], + ).maybeAnimate().fadeIn(), + ), ), )); } diff --git a/lib/ui/screens/photo_gallery/photo_gallery.dart b/lib/ui/screens/photo_gallery/photo_gallery.dart index 3e611a01..104383dc 100644 --- a/lib/ui/screens/photo_gallery/photo_gallery.dart +++ b/lib/ui/screens/photo_gallery/photo_gallery.dart @@ -173,62 +173,63 @@ class _PhotoGalleryState extends State { return FullscreenKeyboardListener( onKeyDown: _handleKeyDown, child: ValueListenableBuilder>( - valueListenable: _photoIds, - builder: (_, value, __) { - if (value.isEmpty) { - return Center(child: AppLoadingIndicator()); - } - Size imgSize = context.isLandscape - ? Size(context.widthPx * .5, context.heightPx * .66) - : Size(context.widthPx * .66, context.heightPx * .5); - imgSize = (widget.imageSize ?? imgSize) * _scale; - // Get transform offset for the current _index - final padding = $styles.insets.md; - var gridOffset = _calculateCurrentOffset(padding, imgSize); - gridOffset += Offset(0, -context.mq.padding.top / 2); - final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; - final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; - return _AnimatedCutoutOverlay( - animationKey: ValueKey(_index), - cutoutSize: imgSize, - swipeDir: _lastSwipeDir, - duration: cutoutTweenDuration, - opacity: _scale == 1 ? .7 : .5, - enabled: useClipPathWorkAroundForWeb == false, - child: SafeArea( - bottom: false, - // Place content in overflow box, to allow it to flow outside the parent - child: OverflowBox( - maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), - maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), - alignment: Alignment.center, - // Detect swipes in order to change index - child: EightWaySwipeDetector( - onSwipe: _handleSwipe, - threshold: 30, - // A tween animation builder moves from image to image based on current offset - child: TweenAnimationBuilder( - tween: Tween(begin: gridOffset, end: gridOffset), - duration: offsetTweenDuration, - curve: Curves.easeOut, - builder: (_, value, child) => Transform.translate(offset: value, child: child), - child: FocusTraversalGroup( - //policy: OrderedTraversalPolicy(), - child: GridView.count( - physics: NeverScrollableScrollPhysics(), - crossAxisCount: _gridSize, - childAspectRatio: imgSize.aspectRatio, - mainAxisSpacing: padding, - crossAxisSpacing: padding, - children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), - ), + valueListenable: _photoIds, + builder: (_, value, __) { + if (value.isEmpty) { + return Center(child: AppLoadingIndicator()); + } + Size imgSize = context.isLandscape + ? Size(context.widthPx * .5, context.heightPx * .66) + : Size(context.widthPx * .66, context.heightPx * .5); + imgSize = (widget.imageSize ?? imgSize) * _scale; + // Get transform offset for the current _index + final padding = $styles.insets.md; + var gridOffset = _calculateCurrentOffset(padding, imgSize); + gridOffset += Offset(0, -context.mq.padding.top / 2); + final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; + final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; + return _AnimatedCutoutOverlay( + animationKey: ValueKey(_index), + cutoutSize: imgSize, + swipeDir: _lastSwipeDir, + duration: cutoutTweenDuration, + opacity: _scale == 1 ? .7 : .5, + enabled: useClipPathWorkAroundForWeb == false, + child: SafeArea( + bottom: false, + // Place content in overflow box, to allow it to flow outside the parent + child: OverflowBox( + maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), + maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), + alignment: Alignment.center, + // Detect swipes in order to change index + child: EightWaySwipeDetector( + onSwipe: _handleSwipe, + threshold: 30, + // A tween animation builder moves from image to image based on current offset + child: TweenAnimationBuilder( + tween: Tween(begin: gridOffset, end: gridOffset), + duration: offsetTweenDuration, + curve: Curves.easeOut, + builder: (_, value, child) => Transform.translate(offset: value, child: child), + child: FocusTraversalGroup( + //policy: OrderedTraversalPolicy(), + child: GridView.count( + physics: NeverScrollableScrollPhysics(), + crossAxisCount: _gridSize, + childAspectRatio: imgSize.aspectRatio, + mainAxisSpacing: padding, + crossAxisSpacing: padding, + children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), ), ), ), ), ), - ); - }), + ), + ); + }, + ), ); }