map: hero fixes

This commit is contained in:
Thibault Deckers 2024-10-24 20:53:29 +02:00
parent 9aeb0a1fc3
commit d96f9768f9
8 changed files with 275 additions and 191 deletions

View file

@ -142,28 +142,28 @@ class AvesAppBar extends StatelessWidget {
static Widget _flightShuttleBuilder(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection direction,
BuildContext fromHero,
BuildContext toHero,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final pushing = direction == HeroFlightDirection.push;
final pushing = flightDirection == HeroFlightDirection.push;
Widget popBuilder(context, child) => Opacity(opacity: 1 - animation.value, child: child);
Widget pushBuilder(context, child) => Opacity(opacity: animation.value, child: child);
return Material(
type: MaterialType.transparency,
child: DefaultTextStyle(
style: DefaultTextStyle.of(toHero).style,
style: DefaultTextStyle.of(toHeroContext).style,
child: Stack(
children: [
AnimatedBuilder(
animation: animation,
builder: pushing ? popBuilder : pushBuilder,
child: fromHero.widget,
child: fromHeroContext.widget,
),
AnimatedBuilder(
animation: animation,
builder: pushing ? pushBuilder : popBuilder,
child: toHero.widget,
child: toHeroContext.widget,
),
],
),

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/text.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -5,6 +6,7 @@ import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves_map/aves_map.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
class Attribution extends StatelessWidget {
final EntryMapStyle? style;
@ -33,9 +35,7 @@ class Attribution extends StatelessWidget {
Widget _buildOsmAttributionMarkdown(BuildContext context, String data) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: MarkdownBody(
Widget child = MarkdownBody(
data: '${context.l10n.mapAttributionOsmData}${AText.separator}$data',
selectable: true,
styleSheet: MarkdownStyleSheet(
@ -43,7 +43,32 @@ class Attribution extends StatelessWidget {
p: theme.textTheme.bodySmall!.merge(const TextStyle(fontSize: InfoRowGroup.fontSize)),
),
onTapLink: (text, href, title) => AvesApp.launchUrl(href),
);
final animate = context.select<Settings, bool>((v) => v.animate);
if (animate) {
child = Hero(
tag: 'map-attribution',
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
return DefaultTextStyle(
style: DefaultTextStyle.of(toHeroContext).style,
child: MediaQuery.removeViewPadding(
context: context,
removeLeft: true,
removeTop: true,
removeRight: true,
removeBottom: true,
child: toHeroContext.widget,
),
);
},
child: child,
);
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: child,
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/widgets/common/map/buttons/button.dart';
import 'package:aves/widgets/common/map/buttons/coordinate_filter.dart';
import 'package:aves/widgets/common/map/compass.dart';
import 'package:aves/widgets/common/map/map_action_delegate.dart';
import 'package:aves/widgets/common/providers/map_theme_provider.dart';
import 'package:aves_map/aves_map.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/material.dart';
@ -52,46 +53,12 @@ class _MapButtonPanelState extends State<MapButtonPanel> {
@override
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
final iconSize = Size.square(iconTheme.size!);
Widget? navigationButton;
switch (context.select<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) {
case MapNavigationButton.back:
if (!settings.useTvLayout) {
navigationButton = MapOverlayButton.icon(
icon: const BackButtonIcon(),
onPressed: () => Navigator.maybeOf(context)?.pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
case MapNavigationButton.close:
navigationButton = MapOverlayButton.icon(
icon: const CloseButtonIcon(),
onPressed: SystemNavigator.pop,
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
);
case MapNavigationButton.map:
final _openMapPage = widget.openMapPage;
if (_openMapPage != null) {
navigationButton = MapOverlayButton.icon(
icon: const Icon(AIcons.showFullscreenCorners),
onPressed: () => _openMapPage.call(context),
tooltip: context.l10n.openMapPageTooltip,
);
}
case MapNavigationButton.none:
break;
}
final showCoordinateFilter = context.select<MapThemeData, bool>((v) => v.showCoordinateFilter);
final visualDensity = context.select<MapThemeData, VisualDensity>((v) => v.visualDensity);
final double padding = 8 + visualDensity.horizontal * 2;
final actions = [
MapAction.openMapApp,
MapAction.addShortcut,
].where((action) => _actionDelegate.isVisible(context, action)).toList();
Widget? topLeftButton = _buildNavigationButton(context);
Widget? topRightButton = _buildTopRightButton(context);
return Positioned.fill(
child: TooltipTheme(
@ -113,38 +80,11 @@ class _MapButtonPanelState extends State<MapButtonPanel> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (navigationButton != null) ...[
navigationButton,
if (topLeftButton != null) ...[
topLeftButton,
SizedBox(height: padding),
],
ValueListenableBuilder<ZoomedBounds>(
valueListenable: widget.boundsNotifier,
builder: (context, bounds, child) {
final degrees = bounds.rotation;
final opacity = degrees == 0 ? .0 : 1.0;
return IgnorePointer(
ignoring: opacity == 0,
child: AnimatedOpacity(
opacity: opacity,
duration: context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation),
child: MapOverlayButton.icon(
icon: Transform(
origin: iconSize.center(Offset.zero),
transform: Matrix4.rotationZ(degToRadian(degrees)),
child: CustomPaint(
painter: CompassPainter(
color: iconTheme.color!,
),
size: iconSize,
),
),
onPressed: widget.controller.resetRotation,
tooltip: context.l10n.mapPointNorthUpTooltip,
),
),
);
},
),
_buildCompass(context),
],
),
),
@ -161,30 +101,10 @@ class _MapButtonPanelState extends State<MapButtonPanel> {
// key is expected by test driver
child: Column(
children: [
if (actions.length == 1) _buildActionButton(context, actions.first),
if (actions.length > 1)
MapOverlayButton(builder: (context, visualDensity, child) {
final animations = context.read<Settings>().accessibilityAnimations;
return PopupMenuButton<MapAction>(
itemBuilder: (context) => actions
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(
text: action.getText(context),
icon: action.getIcon(),
),
))
.toList(),
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
_actionDelegate.onActionSelected(context, action);
},
iconSize: MapOverlayButton.iconSize(visualDensity),
popUpAnimationStyle: animations.popUpAnimationStyle,
);
}),
if (topRightButton != null) ...[
topRightButton,
SizedBox(height: padding),
],
// key is expected by test driver
_buildActionButton(context, MapAction.selectStyle, buttonKey: const Key('map-menu-layers')),
],
@ -212,10 +132,133 @@ class _MapButtonPanelState extends State<MapButtonPanel> {
);
}
Widget _buildActionButton(BuildContext context, MapAction action, {Key? buttonKey}) => MapOverlayButton.icon(
Widget? _buildNavigationButton(BuildContext context) {
Widget? child;
switch (context.select<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) {
case MapNavigationButton.back:
if (!settings.useTvLayout) {
child = MapOverlayButton.icon(
icon: const BackButtonIcon(),
onPressed: () => Navigator.maybeOf(context)?.pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
case MapNavigationButton.close:
child = MapOverlayButton.icon(
icon: const CloseButtonIcon(),
onPressed: SystemNavigator.pop,
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
);
case MapNavigationButton.map:
final _openMapPage = widget.openMapPage;
if (_openMapPage != null) {
child = MapOverlayButton.icon(
icon: const Icon(AIcons.showFullscreenCorners),
onPressed: () => _openMapPage.call(context),
tooltip: context.l10n.openMapPageTooltip,
);
}
case MapNavigationButton.none:
break;
}
if (child != null) {
child = _heroify(context, 'top-left', child);
}
return child;
}
Widget? _buildTopRightButton(BuildContext context) {
const heroTag = 'top-right';
final actions = [
MapAction.openMapApp,
MapAction.addShortcut,
].where((action) => _actionDelegate.isVisible(context, action)).toList();
Widget? child;
if (actions.length == 1) {
child = _buildActionButton(context, actions.first, heroTag: heroTag);
} else if (actions.length > 1) {
child = MapOverlayButton(builder: (context, visualDensity, child) {
final animations = context.read<Settings>().accessibilityAnimations;
return PopupMenuButton<MapAction>(
itemBuilder: (context) => actions
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(
text: action.getText(context),
icon: action.getIcon(),
),
))
.toList(),
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
_actionDelegate.onActionSelected(context, action);
},
iconSize: MapOverlayButton.iconSize(visualDensity),
popUpAnimationStyle: animations.popUpAnimationStyle,
);
});
child = _heroify(context, heroTag, child);
}
return child;
}
Widget _buildCompass(BuildContext context) {
final iconTheme = IconTheme.of(context);
final iconSize = Size.square(iconTheme.size!);
return ValueListenableBuilder<ZoomedBounds>(
valueListenable: widget.boundsNotifier,
builder: (context, bounds, child) {
final degrees = bounds.rotation;
final opacity = degrees == 0 ? .0 : 1.0;
return IgnorePointer(
ignoring: opacity == 0,
child: AnimatedOpacity(
opacity: opacity,
duration: context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation),
child: MapOverlayButton.icon(
icon: Transform(
origin: iconSize.center(Offset.zero),
transform: Matrix4.rotationZ(degToRadian(degrees)),
child: CustomPaint(
painter: CompassPainter(
color: iconTheme.color!,
),
size: iconSize,
),
),
onPressed: widget.controller.resetRotation,
tooltip: context.l10n.mapPointNorthUpTooltip,
),
),
);
},
);
}
Widget _buildActionButton(BuildContext context, MapAction action, {Key? buttonKey, String? heroTag}) {
final child = MapOverlayButton.icon(
buttonKey: buttonKey,
icon: action.getIcon(),
onPressed: () => _actionDelegate.onActionSelected(context, action),
tooltip: action.getText(context),
);
return _heroify(context, heroTag ?? action.name, child);
}
Widget _heroify(BuildContext context, String? tag, Widget child) {
if (tag != null) {
final animate = context.select<Settings, bool>((v) => v.animate);
if (animate) {
return Hero(
tag: 'map-button-$tag',
flightShuttleBuilder: MapTheme.heroFlightShuttleBuilder,
child: child,
);
}
}
return child;
}
}

View file

@ -1,10 +1,12 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/providers/map_theme_provider.dart';
import 'package:aves_map/aves_map.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MapDecorator extends StatelessWidget {
final Widget? child;
final Widget child;
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
static const mapBackground = Color(0xFFDBD5D3);
@ -12,20 +14,12 @@ class MapDecorator extends StatelessWidget {
const MapDecorator({
super.key,
this.child,
required this.child,
});
@override
Widget build(BuildContext context) {
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
return GestureDetector(
onScaleStart: interactive
? null
: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: ClipRRect(
Widget _child = ClipRRect(
borderRadius: mapBorderRadius,
child: Container(
color: mapBackground,
@ -44,11 +38,30 @@ class MapDecorator extends StatelessWidget {
size: Size.infinite,
),
),
if (child != null) child!,
child,
],
),
),
),
);
final animate = context.select<Settings, bool>((v) => v.animate);
if (animate) {
_child = Hero(
tag: 'map-canvas',
flightShuttleBuilder: MapTheme.heroFlightShuttleBuilder,
child: _child,
);
}
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
return GestureDetector(
onScaleStart: interactive
? null
: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: _child,
);
}
}

View file

@ -20,7 +20,6 @@ import 'package:aves/widgets/common/map/attribution.dart';
import 'package:aves/widgets/common/map/buttons/panel.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/leaflet/map.dart';
import 'package:aves/widgets/common/providers/map_theme_provider.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/dialogs/selection_dialogs/common.dart';
import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart';
@ -242,50 +241,6 @@ class _GeoMapState extends State<GeoMap> {
child = _decorateMap(context, overlay);
}
child = Hero(
tag: 'map',
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
final pushing = flightDirection == HeroFlightDirection.push;
final fromMediaQuery = MediaQuery.of(fromHeroContext);
final toMediaQuery = MediaQuery.of(toHeroContext);
final fromRenderBox = fromHeroContext.findRenderObject()! as RenderBox;
final toRenderBox = toHeroContext.findRenderObject()! as RenderBox;
final fromTheme = fromHeroContext.read<MapThemeData>();
final toTheme = toHeroContext.read<MapThemeData>();
return DefaultTextStyle(
style: DefaultTextStyle.of(toHeroContext).style,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final t = pushing ? animation.value : 1 - animation.value;
return MapTheme(
interactive: false,
showCoordinateFilter: false,
navigationButton: toTheme.navigationButton,
visualDensity: VisualDensity.lerp(fromTheme.visualDensity, toTheme.visualDensity, t),
child: MediaQuery(
data: toMediaQuery.copyWith(
padding: EdgeInsets.lerp(fromMediaQuery.padding, toMediaQuery.padding, t),
viewPadding: EdgeInsets.lerp(fromMediaQuery.viewPadding, toMediaQuery.viewPadding, t),
),
child: Align(
alignment: Alignment.topCenter,
child: SizedBox.fromSize(
size: Size.lerp(fromRenderBox.size, toRenderBox.size, t),
child: child,
),
),
),
);
},
child: toHeroContext.widget,
),
);
},
child: child,
);
final mapHeight = context.select<MapThemeData, double?>((v) => v.mapHeight);
child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -319,7 +274,9 @@ class _GeoMapState extends State<GeoMap> {
}
Widget replacement = Stack(
children: [
const MapDecorator(),
const MapDecorator(
child: SizedBox(),
),
_buildButtonPanel(context),
],
);
@ -550,7 +507,7 @@ class _GeoMapState extends State<GeoMap> {
);
}
Widget _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child);
Widget _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child!);
Widget _buildButtonPanel(BuildContext context) {
if (settings.useTvLayout) return const SizedBox();

View file

@ -41,4 +41,50 @@ class MapTheme extends StatelessWidget {
child: child,
);
}
static Widget heroFlightShuttleBuilder(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final pushing = flightDirection == HeroFlightDirection.push;
final fromMediaQuery = MediaQuery.of(fromHeroContext);
final toMediaQuery = MediaQuery.of(toHeroContext);
final fromRenderBox = fromHeroContext.findRenderObject()! as RenderBox;
final toRenderBox = toHeroContext.findRenderObject()! as RenderBox;
final fromTheme = fromHeroContext.read<MapThemeData>();
final toTheme = toHeroContext.read<MapThemeData>();
return DefaultTextStyle(
style: DefaultTextStyle.of(toHeroContext).style,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final t = pushing ? animation.value : 1 - animation.value;
return MapTheme(
interactive: false,
showCoordinateFilter: false,
navigationButton: toTheme.navigationButton,
visualDensity: VisualDensity.lerp(fromTheme.visualDensity, toTheme.visualDensity, t),
child: MediaQuery(
data: toMediaQuery.copyWith(
padding: EdgeInsets.lerp(fromMediaQuery.padding, toMediaQuery.padding, t),
viewPadding: EdgeInsets.lerp(fromMediaQuery.viewPadding, toMediaQuery.viewPadding, t),
),
child: Align(
alignment: Alignment.topCenter,
child: SizedBox.fromSize(
size: Size.lerp(fromRenderBox.size, toRenderBox.size, t),
child: child,
),
),
),
);
},
child: toHeroContext.widget,
),
);
}
}

View file

@ -269,7 +269,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
final backgroundColor = background.isColor ? background.color : null;
image = Hero(
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
Widget child = TransitionImage(
image: entry.bestCachedThumbnail,
animation: animation,
@ -304,11 +304,11 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
if (animate && heroTag != null) {
child = Hero(
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
return MediaQueryDataProvider(
child: DefaultTextStyle(
style: DefaultTextStyle.of(toHero).style,
child: toHero.widget,
style: DefaultTextStyle.of(toHeroContext).style,
child: toHeroContext.widget,
),
);
},

View file

@ -104,11 +104,11 @@ class _AppBottomNavBarState extends State<AppBottomNavBar> {
if (animate) {
child = Hero(
tag: 'nav-bar',
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
return MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: toHero.widget,
child: toHeroContext.widget,
);
},
child: child,