fullscreen: modified widget rebuild logic

This commit is contained in:
Thibault Deckers 2020-06-08 17:07:37 +09:00
parent 5fe985537f
commit be664f0967
3 changed files with 286 additions and 198 deletions

View file

@ -38,7 +38,7 @@ class FullscreenBody extends StatefulWidget {
}
class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
ImageEntry _entry;
final ValueNotifier<ImageEntry> _entryNotifier = ValueNotifier(null);
int _currentHorizontalPage;
ValueNotifier<int> _currentVerticalPage;
PageController _horizontalPager, _verticalPager;
@ -57,19 +57,18 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
List<ImageEntry> get entries => hasCollection ? collection.sortedEntries : [widget.initialEntry];
List<String> get pages => ['transition', 'image', 'info'];
static const int transitionPage = 0;
int get transitionPage => pages.indexOf('transition');
static const int imagePage = 1;
int get imagePage => pages.indexOf('image');
int get infoPage => pages.indexOf('info');
static const int infoPage = 2;
@override
void initState() {
super.initState();
_entry = widget.initialEntry;
_currentHorizontalPage = max(0, entries.indexOf(_entry));
final entry = widget.initialEntry;
_entryNotifier.value = entry;
_currentHorizontalPage = max(0, entries.indexOf(entry));
_currentVerticalPage = ValueNotifier(imagePage);
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
_verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange);
@ -138,76 +137,6 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
@override
Widget build(BuildContext context) {
final topOverlay = ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
final showOverlay = _entry != null && page == imagePage;
return showOverlay
? FullscreenTopOverlay(
entries: entries,
index: _currentHorizontalPage,
scale: _topOverlayScale,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, _entry, action),
)
: const SizedBox.shrink();
},
);
final videoController = _entry != null && _entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == _entry.uri, orElse: () => null)?.item2 : null;
Widget bottomOverlay = Column(
children: [
if (videoController != null)
VideoControlOverlay(
entry: _entry,
controller: videoController,
scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
showPosition: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
],
);
bottomOverlay = ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: _entry != null && _overlayAnimationController.status != AnimationStatus.dismissed,
child: child,
);
},
child: bottomOverlay,
);
bottomOverlay = Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.height,
builder: (c, mqHeight, child) {
// when orientation change, the `PageController` offset is not updated right away
// and it does not trigger its listeners when it does, so we force a refresh in the next frame
WidgetsBinding.instance.addPostFrameCallback((_) => _onVerticalPageControllerChange());
return AnimatedBuilder(
animation: _verticalScrollNotifier,
builder: (context, child) => Positioned(
bottom: (_verticalPager.offset ?? 0) - mqHeight,
child: child,
),
child: child,
);
},
child: bottomOverlay,
);
return WillPopScope(
onWillPop: () {
if (_currentVerticalPage.value == infoPage) {
@ -227,7 +156,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
children: [
FullscreenVerticalPageView(
collection: collection,
entry: _entry,
entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
@ -236,14 +165,106 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
),
topOverlay,
bottomOverlay,
_buildTopOverlay(),
_buildBottomOverlay(),
],
),
),
);
}
Widget _buildTopOverlay() {
final child = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier,
builder: (context, entry, child) {
if (entry == null) return const SizedBox.shrink();
return FullscreenTopOverlay(
entry: entry,
scale: _topOverlayScale,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
);
},
);
return ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
return Visibility(
visible: page == imagePage,
child: child,
);
},
child: child,
);
}
Widget _buildBottomOverlay() {
Widget bottomOverlay = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier,
builder: (context, entry, child) {
Widget videoOverlay;
if (entry != null) {
final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null;
if (videoController != null) {
videoOverlay = VideoControlOverlay(
entry: entry,
controller: videoController,
scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
);
}
}
final child = Column(
children: [
if (videoOverlay != null) videoOverlay,
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
showPosition: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
],
);
return ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed,
child: child,
);
},
child: child,
);
},
);
bottomOverlay = Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.height,
builder: (c, mqHeight, child) {
// when orientation change, the `PageController` offset is not updated right away
// and it does not trigger its listeners when it does, so we force a refresh in the next frame
WidgetsBinding.instance.addPostFrameCallback((_) => _onVerticalPageControllerChange());
return AnimatedBuilder(
animation: _verticalScrollNotifier,
builder: (context, child) => Positioned(
bottom: (_verticalPager.offset ?? 0) - mqHeight,
child: child,
),
child: child,
);
},
child: bottomOverlay,
);
return bottomOverlay;
}
void _onVerticalPageControllerChange() {
_verticalScrollNotifier.notifyListeners();
}
@ -286,11 +307,10 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
void _updateEntry() {
final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entry == newEntry) return;
_entry = newEntry;
if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry;
_pauseVideoControllers();
_initVideoController();
setState(() {});
}
void _onLeave() {
@ -301,13 +321,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_showSystemUI();
}
// system UI
// system UI
static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]);
// overlay
// overlay
Future<void> _initOverlay() async {
// wait for MaterialPageRoute.transitionDuration
@ -348,9 +368,10 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
Future<void> _initVideoController() async {
if (_entry == null || !_entry.isVideo) return;
final entry = _entryNotifier.value;
if (entry == null || !entry.isVideo) return;
final uri = _entry.uri;
final uri = entry.uri;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
@ -362,12 +383,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
while (_videoControllers.length > 3) {
_videoControllers.removeLast().item2.dispose();
}
setState(() {});
}
}
class FullscreenVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ImageEntry entry;
final ValueNotifier<ImageEntry> entryNotifier;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
@ -375,7 +397,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
const FullscreenVerticalPageView({
@required this.collection,
@required this.entry,
@required this.entryNotifier,
@required this.videoControllers,
@required this.verticalPager,
@required this.horizontalPager,
@ -392,12 +414,13 @@ class FullscreenVerticalPageView extends StatefulWidget {
class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView> {
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
final ValueNotifier<bool> _infoPageVisibleNotifier = ValueNotifier(false);
ImageEntry _oldEntry;
CollectionLens get collection => widget.collection;
bool get hasCollection => collection != null;
ImageEntry get entry => widget.entry;
ImageEntry get entry => widget.entryNotifier.value;
@override
void initState() {
@ -420,12 +443,12 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
void _registerWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.addListener(_onVerticalPageControllerChanged);
widget.entry?.imageChangeNotifier?.addListener(_onImageChanged);
widget.entryNotifier.addListener(_onEntryChanged);
}
void _unregisterWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.removeListener(_onVerticalPageControllerChanged);
widget.entry?.imageChangeNotifier?.removeListener(_onImageChanged);
widget.entryNotifier.removeListener(_onEntryChanged);
}
@override
@ -453,7 +476,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
},
child: InfoPage(
collection: collection,
entry: entry,
entryNotifier: widget.entryNotifier,
visibleNotifier: _infoPageVisibleNotifier,
),
),
@ -482,6 +505,14 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
_backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity);
}
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
void _onEntryChanged() {
_oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged);
entry?.imageChangeNotifier?.addListener(_onImageChanged);
_oldEntry = entry;
}
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async {
await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict();
// TODO TLAD also evict `ThumbnailProvider` with specified extents

View file

@ -14,14 +14,14 @@ import 'package:tuple/tuple.dart';
class InfoPage extends StatefulWidget {
final CollectionLens collection;
final ImageEntry entry;
final ValueNotifier<ImageEntry> entryNotifier;
final ValueNotifier<bool> visibleNotifier;
const InfoPage({
Key key,
@required this.collection,
@required this.entry,
this.visibleNotifier,
@required this.entryNotifier,
@required this.visibleNotifier,
}) : super(key: key);
@override
@ -34,12 +34,31 @@ class InfoPageState extends State<InfoPage> {
CollectionLens get collection => widget.collection;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
entry.locate();
_registerWidget(widget);
}
@override
void didUpdateWidget(InfoPage oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(InfoPage widget) {
widget.entryNotifier.addListener(_onEntryChanged);
}
void _unregisterWidget(InfoPage widget) {
widget.entryNotifier.removeListener(_onEntryChanged);
}
@override
@ -67,52 +86,57 @@ class InfoPageState extends State<InfoPage> {
final mqWidth = mq.item1;
final mqViewInsetsBottom = mq.item2;
final split = mqWidth > 400;
final locationAtTop = split && entry.hasGps;
final locationSection = LocationSection(
collection: collection,
entry: entry,
showTitle: !locationAtTop,
visibleNotifier: widget.visibleNotifier,
onFilter: _goToCollection,
);
final basicAndLocationSliver = locationAtTop
? SliverToBoxAdapter(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)),
const SizedBox(width: 8),
Expanded(child: locationSection),
],
),
)
: SliverList(
delegate: SliverChildListDelegate.fixed(
[
BasicSection(entry: entry, collection: collection, onFilter: _goToCollection),
locationSection,
],
),
);
final metadataSliver = MetadataSectionSliver(
entry: entry,
visibleNotifier: widget.visibleNotifier,
);
return ValueListenableBuilder(
valueListenable: widget.entryNotifier,
builder: (context, entry, child) {
final locationAtTop = split && entry.hasGps;
final locationSection = LocationSection(
collection: collection,
entry: entry,
showTitle: !locationAtTop,
visibleNotifier: widget.visibleNotifier,
onFilter: _goToCollection,
);
final basicAndLocationSliver = locationAtTop
? SliverToBoxAdapter(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)),
const SizedBox(width: 8),
Expanded(child: locationSection),
],
),
)
: SliverList(
delegate: SliverChildListDelegate.fixed(
[
BasicSection(entry: entry, collection: collection, onFilter: _goToCollection),
locationSection,
],
),
);
final metadataSliver = MetadataSectionSliver(
entry: entry,
visibleNotifier: widget.visibleNotifier,
);
return CustomScrollView(
controller: _scrollController,
slivers: [
appBar,
SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver,
),
SliverPadding(
padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom),
sliver: metadataSliver,
),
],
return CustomScrollView(
controller: _scrollController,
slivers: [
appBar,
SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver,
),
SliverPadding(
padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom),
sliver: metadataSliver,
),
],
);
},
);
},
),
@ -123,6 +147,10 @@ class InfoPageState extends State<InfoPage> {
);
}
void _onEntryChanged() {
widget.entryNotifier.value?.locate();
}
bool _handleTopScroll(Notification notification) {
if (notification is ScrollNotification) {
if (notification is ScrollStartNotification) {

View file

@ -9,17 +9,15 @@ import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class FullscreenTopOverlay extends StatelessWidget {
final List<ImageEntry> entries;
final int index;
final ImageEntry entry;
final Animation<double> scale;
final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite;
ImageEntry get entry => entries[index];
static const double padding = 8;
static const int landscapeActionCount = 3;
@ -28,13 +26,12 @@ class FullscreenTopOverlay extends StatelessWidget {
const FullscreenTopOverlay({
Key key,
@required this.entries,
@required this.index,
@required this.entry,
@required this.scale,
this.canToggleFavourite = false,
this.viewInsets,
this.viewPadding,
this.onActionSelected,
@required this.canToggleFavourite,
@required this.viewInsets,
@required this.viewPadding,
@required this.onActionSelected,
}) : super(key: key);
@override
@ -43,49 +40,31 @@ class FullscreenTopOverlay extends StatelessWidget {
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
child: Padding(
padding: const EdgeInsets.all(padding),
child: Selector<MediaQueryData, Orientation>(
selector: (c, mq) => mq.orientation,
builder: (c, orientation, child) {
final targetCount = orientation == Orientation.landscape ? landscapeActionCount : portraitActionCount;
return LayoutBuilder(
builder: (context, constraints) {
final availableCount = (constraints.maxWidth / (kMinInteractiveDimension + padding)).floor() - 2;
final quickActionCount = min(targetCount, availableCount);
child: Selector<MediaQueryData, Tuple2<double, Orientation>>(
selector: (c, mq) => Tuple2(mq.size.width, mq.orientation),
builder: (c, mq, child) {
final mqWidth = mq.item1;
final mqOrientation = mq.item2;
final quickActions = [
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.delete,
].where(_canDo).take(quickActionCount);
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo);
final externalAppActions = EntryActions.externalApp.where(_canDo);
final targetCount = mqOrientation == Orientation.landscape ? landscapeActionCount : portraitActionCount;
final availableCount = (mqWidth / (kMinInteractiveDimension + padding)).floor() - 2;
final quickActionCount = min(targetCount, availableCount);
return Row(
children: [
OverlayButton(
scale: scale,
child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(),
),
const Spacer(),
...quickActions.map(_buildOverlayButton),
OverlayButton(
scale: scale,
child: PopupMenuButton<EntryAction>(
itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem),
const PopupMenuDivider(),
...externalAppActions.map(_buildPopupMenuItem),
if (kDebugMode) ...[
const PopupMenuDivider(),
_buildPopupMenuItem(EntryAction.debug),
]
],
onSelected: onActionSelected,
),
),
],
);
},
final quickActions = [
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.delete,
].where(_canDo).take(quickActionCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
return _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
scale: scale,
isFavouriteNotifier: entry.isFavouriteNotifier,
onActionSelected: onActionSelected,
);
},
),
@ -118,6 +97,56 @@ class FullscreenTopOverlay extends StatelessWidget {
}
return false;
}
}
class _TopOverlayRow extends StatelessWidget {
final List<EntryAction> quickActions;
final List<EntryAction> inAppActions;
final List<EntryAction> externalAppActions;
final Animation<double> scale;
final ValueNotifier<bool> isFavouriteNotifier;
final Function(EntryAction value) onActionSelected;
const _TopOverlayRow({
Key key,
@required this.quickActions,
@required this.inAppActions,
@required this.externalAppActions,
@required this.scale,
@required this.isFavouriteNotifier,
@required this.onActionSelected,
}) : super(key: key);
static const double padding = 8;
@override
Widget build(BuildContext context) {
return Row(
children: [
OverlayButton(
scale: scale,
child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(),
),
const Spacer(),
...quickActions.map(_buildOverlayButton),
OverlayButton(
scale: scale,
child: PopupMenuButton<EntryAction>(
itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem),
const PopupMenuDivider(),
...externalAppActions.map(_buildPopupMenuItem),
if (kDebugMode) ...[
const PopupMenuDivider(),
_buildPopupMenuItem(EntryAction.debug),
]
],
onSelected: onActionSelected,
),
),
],
);
}
Widget _buildOverlayButton(EntryAction action) {
Widget child;
@ -125,7 +154,7 @@ class FullscreenTopOverlay extends StatelessWidget {
switch (action) {
case EntryAction.toggleFavourite:
child = ValueListenableBuilder<bool>(
valueListenable: entry.isFavouriteNotifier,
valueListenable: isFavouriteNotifier,
builder: (context, isFavourite, child) => Stack(
alignment: Alignment.center,
children: [
@ -136,7 +165,7 @@ class FullscreenTopOverlay extends StatelessWidget {
),
Sweeper(
builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent),
toggledNotifier: entry.isFavouriteNotifier,
toggledNotifier: isFavouriteNotifier,
),
],
),
@ -178,7 +207,7 @@ class FullscreenTopOverlay extends StatelessWidget {
switch (action) {
// in app actions
case EntryAction.toggleFavourite:
child = entry.isFavouriteNotifier.value
child = isFavouriteNotifier.value
? const MenuRow(
text: 'Remove from favourites',
icon: AIcons.favouriteActive,