diff --git a/lib/ui/common/controls/eight_way_swipe_detector.dart b/lib/ui/common/controls/eight_way_swipe_detector.dart index f4a65257..06efebff 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,22 @@ class _EightWaySwipeDetectorState extends State { @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onPanStart: _handleSwipeStart, - onPanUpdate: _handleSwipeUpdate, - onPanCancel: _resetSwipe, - onPanEnd: _handleSwipeEnd, - child: widget.child); + return TrackpadListener( + onScroll: _trackpadSwipe, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: _handleSwipeStart, + onPanUpdate: _handleSwipeUpdate, + onPanCancel: _resetSwipe, + onPanEnd: _handleSwipeEnd, + 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 index 5d02e7bc..c26d9e89 100644 --- a/lib/ui/common/controls/trackpad_listener.dart +++ b/lib/ui/common/controls/trackpad_listener.dart @@ -1,65 +1,83 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:wonders/common_libs.dart'; class TrackpadListener extends StatefulWidget { - static const int swipeSensitivity = 15; - static const int scrollSensitivity = 100; + final Widget child; + final double scrollSensitivity; + final void Function()? onScrollUp; + final void Function()? onScrollDown; + final void Function()? onScrollLeft; + final void Function()? onScrollRight; + final void Function(Offset delta)? onScroll; const TrackpadListener({ super.key, required this.child, - this.swipeUp, - this.swipeDown, - this.swipeLeft, - this.swipeRight, - this.scrollUp, - this.scrollDown, - this.scrollLeft, - this.scrollRight, + this.scrollSensitivity = 100, + this.onScrollUp, + this.onScrollDown, + this.onScrollLeft, + this.onScrollRight, + this.onScroll, }); - final Widget child; - final void Function()? swipeUp; - final void Function()? swipeDown; - final void Function()? swipeLeft; - final void Function()? swipeRight; - final void Function()? scrollUp; - final void Function()? scrollDown; - final void Function()? scrollLeft; - final void Function()? scrollRight; - @override State createState() => _TrackpadListenerState(); } class _TrackpadListenerState extends State { + Offset _scrollOffset = Offset.zero; void _handleTrackpadEvent(PointerSignalEvent event) { GestureBinding.instance.pointerSignalResolver.register(event, (PointerSignalEvent event) { if (event is PointerScrollEvent && event.kind == PointerDeviceKind.trackpad) { - if (event.scrollDelta.dy > TrackpadListener.swipeSensitivity) { - widget.swipeDown?.call(); - } else if (event.scrollDelta.dy < -TrackpadListener.swipeSensitivity) { - widget.swipeUp?.call(); - } - if (event.scrollDelta.dx > TrackpadListener.swipeSensitivity) { - widget.swipeLeft?.call(); - } else if (event.scrollDelta.dx < -TrackpadListener.swipeSensitivity) { - widget.swipeRight?.call(); - } + Offset newScroll = _scrollOffset + event.scrollDelta; + newScroll = Offset( + newScroll.dx.clamp(-widget.scrollSensitivity, widget.scrollSensitivity), + newScroll.dy.clamp(-widget.scrollSensitivity, widget.scrollSensitivity), + ); + _update(newScroll); } }); } + void _update(Offset newOffset) { + Offset directionScroll = Offset.zero; + double sensitivity = widget.scrollSensitivity; + while (newOffset.dy >= sensitivity) { + widget.onScrollDown?.call(); + newOffset -= Offset(0.0, sensitivity); + directionScroll -= Offset(0.0, 1.0); + } + while (newOffset.dy <= -sensitivity) { + widget.onScrollUp?.call(); + newOffset += Offset(0.0, sensitivity); + directionScroll += Offset(0.0, 1.0); + } + while (newOffset.dx >= sensitivity) { + widget.onScrollLeft?.call(); + newOffset -= Offset(sensitivity, 0.0); + directionScroll -= Offset(1.0, 0.0); + } + while (newOffset.dx <= -sensitivity) { + widget.onScrollRight?.call(); + newOffset += Offset(sensitivity, 0.0); + directionScroll += Offset(1.0, 0.0); + } + if (directionScroll != Offset.zero) { + widget.onScroll?.call(directionScroll); + } + // Tone it down over time. + newOffset *= 0.9; + setState(() => _scrollOffset = newOffset); + } + @override Widget build(BuildContext context) { return Listener( - onPointerPanZoomStart: (event) { - debugPrint(' - TrackpadReader: onPointerPanZoomStart'); - }, - onPointerPanZoomEnd: (event) { - debugPrint(' - TrackpadReader: onPointerPanZoomEnd'); - }, - onPointerSignal: _handleTrackpadEvent, - child: widget.child); + onPointerSignal: _handleTrackpadEvent, + child: widget.child, + ); } } diff --git a/lib/ui/screens/editorial/editorial_screen.dart b/lib/ui/screens/editorial/editorial_screen.dart index 073ff240..94276a5c 100644 --- a/lib/ui/screens/editorial/editorial_screen.dart +++ b/lib/ui/screens/editorial/editorial_screen.dart @@ -12,6 +12,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'; @@ -64,6 +65,13 @@ class _WonderEditorialScreenState extends State { _scrollPos.value = _scroller.position.pixels; } + void _handleSwipeUp() { + // Trackpad swipe up. Return to home if at the top. + if (_scroller.position.pixels == 0) { + _handleBackPressed(); + } + } + void _handleBackPressed() => context.go(ScreenPaths.home); @override @@ -80,115 +88,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( + onScrollUp: _handleSwipeUp, + 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 e1172c43..a9abb481 100644 --- a/lib/ui/screens/home/wonders_home_screen.dart +++ b/lib/ui/screens/home/wonders_home_screen.dart @@ -3,6 +3,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'; @@ -140,25 +141,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, + onScrollDown: () => _showDetailsPage(), + 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(), - ], - ).animate().fadeIn(), + /// Controls that float on top of the various illustrations + _buildFloatingUi(), + ], + ).animate().fadeIn(), + ), ), )); } diff --git a/lib/ui/screens/photo_gallery/photo_gallery.dart b/lib/ui/screens/photo_gallery/photo_gallery.dart index 009490e2..521314e9 100644 --- a/lib/ui/screens/photo_gallery/photo_gallery.dart +++ b/lib/ui/screens/photo_gallery/photo_gallery.dart @@ -5,7 +5,6 @@ import 'package:wonders/common_libs.dart'; import 'package:wonders/logic/data/unsplash_photo_data.dart'; import 'package:wonders/ui/common/controls/app_loading_indicator.dart'; import 'package:wonders/ui/common/controls/eight_way_swipe_detector.dart'; -import 'package:wonders/ui/common/controls/trackpad_listener.dart'; import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; import 'package:wonders/ui/common/hidden_collectible.dart'; import 'package:wonders/ui/common/ignore_pointer.dart'; @@ -170,83 +169,65 @@ class _PhotoGalleryState extends State { @override Widget build(BuildContext context) { - return TrackpadListener( - swipeLeft: () { - debugPrint('LEFT!'); - _handleSwipe(Offset(-1, 0)); - }, - swipeRight: () { - debugPrint('RIGHT!'); - _handleSwipe(Offset(1, 0)); - }, - swipeDown: () { - debugPrint('DOWN!'); - _handleSwipe(Offset(0, -1)); - }, - swipeUp: () { - debugPrint('UP!'); - _handleSwipe(Offset(0, 1)); - }, - child: 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)), - ), + 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)), ), ), ), ), ), - ); - }, - ), + ), + ); + }, ), ); }