Add arrowkey support for navigation

Add workaround for lack of cutout support on web
This commit is contained in:
Shawn 2023-11-29 16:01:08 -07:00
parent 5b595c3f31
commit 2887de4704
2 changed files with 181 additions and 106 deletions

View File

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:wonders/common_libs.dart'; import 'package:wonders/common_libs.dart';
import 'package:wonders/logic/data/unsplash_photo_data.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/app_loading_indicator.dart';
import 'package:wonders/ui/common/controls/eight_way_swipe_detector.dart'; import 'package:wonders/ui/common/controls/eight_way_swipe_detector.dart';
import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart';
import 'package:wonders/ui/common/hidden_collectible.dart'; import 'package:wonders/ui/common/hidden_collectible.dart';
import 'package:wonders/ui/common/modals/fullscreen_url_img_viewer.dart'; import 'package:wonders/ui/common/modals/fullscreen_url_img_viewer.dart';
import 'package:wonders/ui/common/unsplash_photo.dart'; import 'package:wonders/ui/common/unsplash_photo.dart';
@ -33,10 +35,16 @@ class _PhotoGalleryState extends State<PhotoGallery> {
final _photoIds = ValueNotifier<List<String>>([]); final _photoIds = ValueNotifier<List<String>>([]);
int get _imgCount => pow(_gridSize, 2).round(); int get _imgCount => pow(_gridSize, 2).round();
late final List<FocusNode> _focusNodes = List.generate(_imgCount, (index) => FocusNode());
//TODO: Remove this field (and associated workarounds) once web properly supports ClipPath (https://github.com/flutter/flutter/issues/124675)
final bool useClipPathWorkAroundForWeb = kIsWeb;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initPhotoIds(); _initPhotoIds();
_focusNodes[_index].requestFocus();
} }
Future<void> _initPhotoIds() async { Future<void> _initPhotoIds() async {
@ -55,6 +63,7 @@ class _PhotoGalleryState extends State<PhotoGallery> {
if (value < 0 || value >= _imgCount) return; if (value < 0 || value >= _imgCount) return;
_skipNextOffsetTween = skipAnimation; _skipNextOffsetTween = skipAnimation;
setState(() => _index = value); setState(() => _index = value);
_focusNodes[value].requestFocus();
} }
/// Determine the required offset to show the current selected index. /// Determine the required offset to show the current selected index.
@ -89,6 +98,37 @@ class _PhotoGalleryState extends State<PhotoGallery> {
} }
} }
bool _handleKeyDown(KeyDownEvent event) {
var newIndex = -1;
bool handled = false;
final key = event.logicalKey;
Map<LogicalKeyboardKey, int> keyActions = {
LogicalKeyboardKey.arrowUp: -_gridSize,
LogicalKeyboardKey.arrowDown: _gridSize,
LogicalKeyboardKey.arrowRight: 1,
LogicalKeyboardKey.arrowLeft: -1,
};
int? action = keyActions[key];
if (action != null) {
newIndex = _index + action;
handled = true;
bool isRightSide = _index % _gridSize == _gridSize - 1;
if (isRightSide && key == LogicalKeyboardKey.arrowRight) {
newIndex = -1;
}
bool isLeftSide = _index % _gridSize == 0;
if (isLeftSide && key == LogicalKeyboardKey.arrowLeft) newIndex = -1;
if (newIndex > _gridSize * _gridSize) {
newIndex = -1;
}
if (newIndex >= 0) {
_setIndex(newIndex);
}
}
return handled;
}
/// Converts a swipe direction into a new index /// Converts a swipe direction into a new index
void _handleSwipe(Offset dir) { void _handleSwipe(Offset dir) {
// Calculate new index, y swipes move by an entire row, x swipes move one index at a time // Calculate new index, y swipes move by an entire row, x swipes move one index at a time
@ -104,7 +144,8 @@ class _PhotoGalleryState extends State<PhotoGallery> {
_setIndex(newIndex); _setIndex(newIndex);
} }
Future<void> _handleImageTapped(int index) async { Future<void> _handleImageTapped(int index, bool isSelected) async {
if (_checkCollectibleIndex(index) && isSelected) return;
if (_index == index) { if (_index == index) {
final urls = _photoIds.value.map((e) { final urls = _photoIds.value.map((e) {
return UnsplashPhotoData.getSelfHostedUrl(e, UnsplashPhotoSize.xl); return UnsplashPhotoData.getSelfHostedUrl(e, UnsplashPhotoSize.xl);
@ -122,119 +163,150 @@ class _PhotoGalleryState extends State<PhotoGallery> {
} }
} }
void _handleImageFocusChanged(int index, bool isFocused) {
if (isFocused) {
_setIndex(index);
}
}
bool _checkCollectibleIndex(int index) { bool _checkCollectibleIndex(int index) {
return index == _getCollectibleIndex() && collectiblesLogic.isLost(widget.wonderType, 1); return index == _getCollectibleIndex() && collectiblesLogic.isLost(widget.wonderType, 1);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<List<String>>( return FullScreenKeyboardListener(
valueListenable: _photoIds, onKeyDown: _handleKeyDown,
builder: (_, value, __) { child: ValueListenableBuilder<List<String>>(
if (value.isEmpty) { valueListenable: _photoIds,
return Center(child: AppLoadingIndicator()); builder: (_, value, __) {
} if (value.isEmpty) {
Size imgSize = context.isLandscape return Center(child: AppLoadingIndicator());
? Size(context.widthPx * .5, context.heightPx * .66) }
: Size(context.widthPx * .66, context.heightPx * .5); Size imgSize = context.isLandscape
imgSize = (widget.imageSize ?? imgSize) * _scale; ? Size(context.widthPx * .5, context.heightPx * .66)
// Get transform offset for the current _index : Size(context.widthPx * .66, context.heightPx * .5);
final padding = $styles.insets.md; imgSize = (widget.imageSize ?? imgSize) * _scale;
var gridOffset = _calculateCurrentOffset(padding, imgSize); // Get transform offset for the current _index
gridOffset += Offset(0, -context.mq.padding.top / 2); final padding = $styles.insets.md;
final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; var gridOffset = _calculateCurrentOffset(padding, imgSize);
final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; gridOffset += Offset(0, -context.mq.padding.top / 2);
return _AnimatedCutoutOverlay( final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration;
animationKey: ValueKey(_index), final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5;
cutoutSize: imgSize, return _AnimatedCutoutOverlay(
swipeDir: _lastSwipeDir, animationKey: ValueKey(_index),
duration: cutoutTweenDuration, cutoutSize: imgSize,
opacity: _scale == 1 ? .7 : .5, swipeDir: _lastSwipeDir,
child: SafeArea( duration: cutoutTweenDuration,
bottom: false, opacity: _scale == 1 ? .7 : .5,
// Place content in overflow box, to allow it to flow outside the parent enabled: useClipPathWorkAroundForWeb == false,
child: OverflowBox( child: SafeArea(
maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), bottom: false,
maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), // Place content in overflow box, to allow it to flow outside the parent
alignment: Alignment.center, child: OverflowBox(
// Detect swipes in order to change index maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1),
child: EightWaySwipeDetector( maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1),
onSwipe: _handleSwipe, alignment: Alignment.center,
threshold: 30, // Detect swipes in order to change index
// A tween animation builder moves from image to image based on current offset child: EightWaySwipeDetector(
child: TweenAnimationBuilder<Offset>( onSwipe: _handleSwipe,
tween: Tween(begin: gridOffset, end: gridOffset), threshold: 30,
duration: offsetTweenDuration, // A tween animation builder moves from image to image based on current offset
curve: Curves.easeOut, child: TweenAnimationBuilder<Offset>(
builder: (_, value, child) => Transform.translate(offset: value, child: child), tween: Tween(begin: gridOffset, end: gridOffset),
child: GridView.count( duration: offsetTweenDuration,
physics: NeverScrollableScrollPhysics(), curve: Curves.easeOut,
crossAxisCount: _gridSize, builder: (_, value, child) => Transform.translate(offset: value, child: child),
childAspectRatio: imgSize.aspectRatio, child: FocusTraversalGroup(
mainAxisSpacing: padding, //policy: OrderedTraversalPolicy(),
crossAxisSpacing: padding, child: GridView.count(
children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), physics: NeverScrollableScrollPhysics(),
crossAxisCount: _gridSize,
childAspectRatio: imgSize.aspectRatio,
mainAxisSpacing: padding,
crossAxisSpacing: padding,
children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)),
),
),
), ),
), ),
), ),
), ),
), );
); }),
}); );
} }
Widget _buildImage(int index, Duration swipeDuration, Size imgSize) { Widget _buildImage(int index, Duration swipeDuration, Size imgSize) {
/// Bind to collectibles.statesById because we might need to rebuild if a collectible is found. /// Bind to collectibles.statesById because we might need to rebuild if a collectible is found.
return ValueListenableBuilder( return FocusTraversalOrder(
valueListenable: collectiblesLogic.statesById, order: NumericFocusOrder(index.toDouble()),
builder: (_, __, ___) { child: ValueListenableBuilder(
bool selected = index == _index; valueListenable: collectiblesLogic.statesById,
final imgUrl = _photoIds.value[index]; builder: (_, __, ___) {
bool isSelected = index == _index;
final imgUrl = _photoIds.value[index];
late String semanticLbl;
if (_checkCollectibleIndex(index)) {
semanticLbl = $strings.collectibleItemSemanticCollectible;
} else {
semanticLbl = !isSelected
? $strings.photoGallerySemanticFocus(index + 1, _imgCount)
: $strings.photoGallerySemanticFullscreen(index + 1, _imgCount);
}
late String semanticLbl; final photoWidget = TweenAnimationBuilder<double>(
if (_checkCollectibleIndex(index)) { duration: $styles.times.med,
semanticLbl = $strings.collectibleItemSemanticCollectible; curve: Curves.easeOut,
} else { tween: Tween(begin: 1, end: isSelected ? 1.15 : 1),
semanticLbl = !selected builder: (_, value, child) => Transform.scale(scale: value, child: child),
? $strings.photoGallerySemanticFocus(index + 1, _imgCount) child: UnsplashPhoto(
: $strings.photoGallerySemanticFullscreen(index + 1, _imgCount); imgUrl,
} fit: BoxFit.cover,
return MergeSemantics( size: UnsplashPhotoSize.large,
child: Semantics( ).animate().fade(),
focused: selected, );
image: !_checkCollectibleIndex(index),
liveRegion: selected, return MergeSemantics(
onIncrease: () => _handleImageTapped(_index + 1), child: Semantics(
onDecrease: () => _handleImageTapped(_index - 1), focused: isSelected,
child: AppBtn.basic( image: !_checkCollectibleIndex(index),
semanticLabel: semanticLbl, liveRegion: isSelected,
onPressed: () { onIncrease: () => _handleImageTapped(_index + 1, false),
if (_checkCollectibleIndex(index) && selected) return; onDecrease: () => _handleImageTapped(_index - 1, false),
_handleImageTapped(index); child: AppBtn.basic(
}, semanticLabel: semanticLbl,
child: _checkCollectibleIndex(index) focusNode: _focusNodes[index],
? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100)) onFocusChanged: (isFocused) => _handleImageFocusChanged(index, isFocused),
: ClipRRect( onPressed: () => _handleImageTapped(index, isSelected),
borderRadius: BorderRadius.circular(8), child: _checkCollectibleIndex(index)
child: SizedBox( ? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100))
width: imgSize.width, : ClipRRect(
height: imgSize.height, borderRadius: BorderRadius.circular(8),
child: TweenAnimationBuilder<double>( child: SizedBox(
duration: $styles.times.med, width: imgSize.width,
curve: Curves.easeOut, height: imgSize.height,
tween: Tween(begin: 1, end: selected ? 1.15 : 1), child: (useClipPathWorkAroundForWeb == false)
builder: (_, value, child) => Transform.scale(scale: value, child: child), ? photoWidget
child: UnsplashPhoto( : Stack(
imgUrl, children: [
fit: BoxFit.cover, photoWidget,
size: UnsplashPhotoSize.large, // Because the web platform doesn't support clipPath, we use a workaround to highlight the selected image
).animate().fade(), Positioned.fill(
child: AnimatedOpacity(
duration: $styles.times.med,
opacity: isSelected ? 0 : .7,
child: ColoredBox(color: $styles.colors.black),
),
),
],
),
), ),
), ),
), ),
), ),
), );
); }),
}); );
} }
} }

View File

@ -4,23 +4,26 @@ part of '../photo_gallery.dart';
/// When animationKey changes, the box animates its size, shrinking then returning to its original size. /// When animationKey changes, the box animates its size, shrinking then returning to its original size.
/// Uses[_CutoutClipper] to create the cutout. /// Uses[_CutoutClipper] to create the cutout.
class _AnimatedCutoutOverlay extends StatelessWidget { class _AnimatedCutoutOverlay extends StatelessWidget {
const _AnimatedCutoutOverlay( const _AnimatedCutoutOverlay({
{Key? key, Key? key,
required this.child, required this.child,
required this.cutoutSize, required this.cutoutSize,
required this.animationKey, required this.animationKey,
this.duration, this.duration,
required this.swipeDir, required this.swipeDir,
required this.opacity}) required this.opacity,
: super(key: key); required this.enabled,
}) : super(key: key);
final Widget child; final Widget child;
final Size cutoutSize; final Size cutoutSize;
final Key animationKey; final Key animationKey;
final Offset swipeDir; final Offset swipeDir;
final Duration? duration; final Duration? duration;
final double opacity; final double opacity;
final bool enabled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!enabled) return child;
return Stack( return Stack(
children: [ children: [
child, child,