Add arrowkey support for navigation
Add workaround for lack of cutout support on web
This commit is contained in:
parent
5b595c3f31
commit
2887de4704
@ -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,13 +163,21 @@ 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(
|
||||||
|
onKeyDown: _handleKeyDown,
|
||||||
|
child: ValueListenableBuilder<List<String>>(
|
||||||
valueListenable: _photoIds,
|
valueListenable: _photoIds,
|
||||||
builder: (_, value, __) {
|
builder: (_, value, __) {
|
||||||
if (value.isEmpty) {
|
if (value.isEmpty) {
|
||||||
@ -150,6 +199,7 @@ class _PhotoGalleryState extends State<PhotoGallery> {
|
|||||||
swipeDir: _lastSwipeDir,
|
swipeDir: _lastSwipeDir,
|
||||||
duration: cutoutTweenDuration,
|
duration: cutoutTweenDuration,
|
||||||
opacity: _scale == 1 ? .7 : .5,
|
opacity: _scale == 1 ? .7 : .5,
|
||||||
|
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
|
||||||
@ -167,6 +217,8 @@ class _PhotoGalleryState extends State<PhotoGallery> {
|
|||||||
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(
|
||||||
|
//policy: OrderedTraversalPolicy(),
|
||||||
child: GridView.count(
|
child: GridView.count(
|
||||||
physics: NeverScrollableScrollPhysics(),
|
physics: NeverScrollableScrollPhysics(),
|
||||||
crossAxisCount: _gridSize,
|
crossAxisCount: _gridSize,
|
||||||
@ -179,39 +231,54 @@ class _PhotoGalleryState extends State<PhotoGallery> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
order: NumericFocusOrder(index.toDouble()),
|
||||||
|
child: ValueListenableBuilder(
|
||||||
valueListenable: collectiblesLogic.statesById,
|
valueListenable: collectiblesLogic.statesById,
|
||||||
builder: (_, __, ___) {
|
builder: (_, __, ___) {
|
||||||
bool selected = index == _index;
|
bool isSelected = index == _index;
|
||||||
final imgUrl = _photoIds.value[index];
|
final imgUrl = _photoIds.value[index];
|
||||||
|
|
||||||
late String semanticLbl;
|
late String semanticLbl;
|
||||||
if (_checkCollectibleIndex(index)) {
|
if (_checkCollectibleIndex(index)) {
|
||||||
semanticLbl = $strings.collectibleItemSemanticCollectible;
|
semanticLbl = $strings.collectibleItemSemanticCollectible;
|
||||||
} else {
|
} else {
|
||||||
semanticLbl = !selected
|
semanticLbl = !isSelected
|
||||||
? $strings.photoGallerySemanticFocus(index + 1, _imgCount)
|
? $strings.photoGallerySemanticFocus(index + 1, _imgCount)
|
||||||
: $strings.photoGallerySemanticFullscreen(index + 1, _imgCount);
|
: $strings.photoGallerySemanticFullscreen(index + 1, _imgCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final photoWidget = TweenAnimationBuilder<double>(
|
||||||
|
duration: $styles.times.med,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
tween: Tween(begin: 1, end: isSelected ? 1.15 : 1),
|
||||||
|
builder: (_, value, child) => Transform.scale(scale: value, child: child),
|
||||||
|
child: UnsplashPhoto(
|
||||||
|
imgUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
size: UnsplashPhotoSize.large,
|
||||||
|
).animate().fade(),
|
||||||
|
);
|
||||||
|
|
||||||
return MergeSemantics(
|
return MergeSemantics(
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
focused: selected,
|
focused: isSelected,
|
||||||
image: !_checkCollectibleIndex(index),
|
image: !_checkCollectibleIndex(index),
|
||||||
liveRegion: selected,
|
liveRegion: isSelected,
|
||||||
onIncrease: () => _handleImageTapped(_index + 1),
|
onIncrease: () => _handleImageTapped(_index + 1, false),
|
||||||
onDecrease: () => _handleImageTapped(_index - 1),
|
onDecrease: () => _handleImageTapped(_index - 1, false),
|
||||||
child: AppBtn.basic(
|
child: AppBtn.basic(
|
||||||
semanticLabel: semanticLbl,
|
semanticLabel: semanticLbl,
|
||||||
onPressed: () {
|
focusNode: _focusNodes[index],
|
||||||
if (_checkCollectibleIndex(index) && selected) return;
|
onFocusChanged: (isFocused) => _handleImageFocusChanged(index, isFocused),
|
||||||
_handleImageTapped(index);
|
onPressed: () => _handleImageTapped(index, isSelected),
|
||||||
},
|
|
||||||
child: _checkCollectibleIndex(index)
|
child: _checkCollectibleIndex(index)
|
||||||
? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100))
|
? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100))
|
||||||
: ClipRRect(
|
: ClipRRect(
|
||||||
@ -219,22 +286,27 @@ class _PhotoGalleryState extends State<PhotoGallery> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: imgSize.width,
|
width: imgSize.width,
|
||||||
height: imgSize.height,
|
height: imgSize.height,
|
||||||
child: TweenAnimationBuilder<double>(
|
child: (useClipPathWorkAroundForWeb == false)
|
||||||
|
? photoWidget
|
||||||
|
: Stack(
|
||||||
|
children: [
|
||||||
|
photoWidget,
|
||||||
|
// Because the web platform doesn't support clipPath, we use a workaround to highlight the selected image
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedOpacity(
|
||||||
duration: $styles.times.med,
|
duration: $styles.times.med,
|
||||||
curve: Curves.easeOut,
|
opacity: isSelected ? 0 : .7,
|
||||||
tween: Tween(begin: 1, end: selected ? 1.15 : 1),
|
child: ColoredBox(color: $styles.colors.black),
|
||||||
builder: (_, value, child) => Transform.scale(scale: value, child: child),
|
),
|
||||||
child: UnsplashPhoto(
|
),
|
||||||
imgUrl,
|
],
|
||||||
fit: BoxFit.cover,
|
|
||||||
size: UnsplashPhotoSize.large,
|
|
||||||
).animate().fade(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user