Add scrollbar in photos page

This commit is contained in:
Ming Ming 2021-05-20 03:11:06 +08:00
parent 80147c35ed
commit 835426e534
6 changed files with 376 additions and 62 deletions

View file

@ -54,7 +54,10 @@ class AlbumViewer extends StatefulWidget {
}
class _AlbumViewerState extends State<AlbumViewer>
with SelectableItemStreamListMixin<AlbumViewer>, TickerProviderStateMixin {
with
WidgetsBindingObserver,
SelectableItemStreamListMixin<AlbumViewer>,
TickerProviderStateMixin {
@override
initState() {
super.initState();

View file

@ -1,3 +1,4 @@
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -24,6 +25,7 @@ import 'package:nc_photos/use_case/remove.dart';
import 'package:nc_photos/use_case/update_album.dart';
import 'package:nc_photos/widget/album_picker_dialog.dart';
import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/measure.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/popup_menu_zoom.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
@ -42,7 +44,7 @@ class HomePhotos extends StatefulWidget {
}
class _HomePhotosState extends State<HomePhotos>
with SelectableItemStreamListMixin<HomePhotos> {
with WidgetsBindingObserver, SelectableItemStreamListMixin<HomePhotos> {
@override
initState() {
super.initState();
@ -62,6 +64,11 @@ class _HomePhotosState extends State<HomePhotos>
);
}
@override
void onMaxExtentChanged(double maxExtent) {
setState(() {});
}
@override
int get itemStreamListCellSize => _thumbSize;
@ -77,32 +84,50 @@ class _HomePhotosState extends State<HomePhotos>
}
Widget _buildContent(BuildContext context, ScanDirBlocState state) {
return Stack(
children: [
buildItemStreamListOuter(
context,
child: Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: buildItemStreamList(context),
return LayoutBuilder(builder: (context, constraints) {
if (_prevListWidth == null) {
_prevListWidth = constraints.maxWidth;
}
if (constraints.maxWidth != _prevListWidth) {
_log.info(
"[_buildContent] updateListHeight: list viewport width changed");
WidgetsBinding.instance.addPostFrameCallback((_) => updateListHeight());
_prevListWidth = constraints.maxWidth;
}
final scrollExtent = _getScrollViewExtent(constraints);
return Stack(
children: [
buildItemStreamListOuter(
context,
child: Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: DraggableScrollbar.semicircle(
controller: _scrollController,
overrideMaxScrollExtent: scrollExtent,
child: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: buildItemStreamList(context),
),
],
),
],
),
),
),
),
if (state is ScanDirBlocLoading)
Align(
alignment: Alignment.bottomCenter,
child: const LinearProgressIndicator(),
),
],
);
if (state is ScanDirBlocLoading)
Align(
alignment: Alignment.bottomCenter,
child: const LinearProgressIndicator(),
),
],
);
});
}
Widget _buildAppBar(BuildContext context) {
@ -152,40 +177,45 @@ class _HomePhotosState extends State<HomePhotos>
}
Widget _buildNormalAppBar(BuildContext context) {
return HomeSliverAppBar(
account: widget.account,
actions: [
PopupMenuButton(
icon: const Icon(Icons.zoom_in),
tooltip: AppLocalizations.of(context).zoomTooltip,
itemBuilder: (context) => [
PopupMenuZoom(
initialValue: _thumbZoomLevel,
minValue: -1,
maxValue: 2,
onChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round());
});
Pref.inst().setHomePhotosZoomLevel(_thumbZoomLevel);
},
),
],
),
],
menuActions: [
PopupMenuItem(
value: _menuValueRefresh,
child: Text(AppLocalizations.of(context).refreshMenuLabel),
),
],
onSelectedMenuActions: (option) {
switch (option) {
case _menuValueRefresh:
_onRefreshSelected();
break;
}
return SliverMeasureExtent(
onChange: (extent) {
_appBarExtent = extent;
},
child: HomeSliverAppBar(
account: widget.account,
actions: [
PopupMenuButton(
icon: const Icon(Icons.zoom_in),
tooltip: AppLocalizations.of(context).zoomTooltip,
itemBuilder: (context) => [
PopupMenuZoom(
initialValue: _thumbZoomLevel,
minValue: -1,
maxValue: 2,
onChanged: (value) {
setState(() {
_setThumbZoomLevel(value.round());
});
Pref.inst().setHomePhotosZoomLevel(_thumbZoomLevel);
},
),
],
),
],
menuActions: [
PopupMenuItem(
value: _menuValueRefresh,
child: Text(AppLocalizations.of(context).refreshMenuLabel),
),
],
onSelectedMenuActions: (option) {
switch (option) {
case _menuValueRefresh:
_onRefreshSelected();
break;
}
},
),
);
}
@ -411,6 +441,22 @@ class _HomePhotosState extends State<HomePhotos>
}
}
/// Return the estimated scroll extent of the custom scroll view, or null
double _getScrollViewExtent(BoxConstraints constraints) {
if (calculatedMaxExtent != null &&
constraints.hasBoundedHeight &&
_appBarExtent != null) {
// scroll extent = list height - widget viewport height + sliver app bar height
final scrollExtent =
calculatedMaxExtent - constraints.maxHeight + _appBarExtent;
_log.info(
"[_getScrollViewExtent] $calculatedMaxExtent - ${constraints.maxHeight} + $_appBarExtent = $scrollExtent");
return scrollExtent;
} else {
return null;
}
}
int get _thumbSize {
switch (_thumbZoomLevel) {
case -1:
@ -434,6 +480,11 @@ class _HomePhotosState extends State<HomePhotos>
var _thumbZoomLevel = 0;
final ScrollController _scrollController = ScrollController();
double _prevListWidth;
double _appBarExtent;
static final _log = Logger("widget.home_photos._HomePhotosState");
static const _menuValueRefresh = 0;
}

76
lib/widget/measure.dart Normal file
View file

@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
typedef void OnWidgetSizeChanged(Size size);
/// See: https://stackoverflow.com/a/60868972
class MeasureSize extends SingleChildRenderObjectWidget {
final OnWidgetSizeChanged onChange;
const MeasureSize({
Key key,
@required this.onChange,
@required Widget child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange);
}
}
class _MeasureSizeRenderObject extends RenderProxyBox {
Size oldSize;
final OnWidgetSizeChanged onChange;
_MeasureSizeRenderObject(this.onChange);
@override
void performLayout() {
super.performLayout();
Size newSize = child.size;
if (oldSize == newSize) return;
oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
class SliverMeasureExtent extends SingleChildRenderObjectWidget {
const SliverMeasureExtent({
Key key,
@required this.onChange,
@required Widget child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return _SliverMeasureExtentRenderObject(onChange);
}
final void Function(double) onChange;
}
class _SliverMeasureExtentRenderObject extends RenderProxySliver {
_SliverMeasureExtentRenderObject(this.onChange);
@override
void performLayout() {
super.performLayout();
double newExent = child.geometry.scrollExtent;
if (_oldExtent == newExent) {
return;
}
_oldExtent = newExent;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newExent));
}
final void Function(double) onChange;
double _oldExtent;
}

View file

@ -27,11 +27,36 @@ abstract class SelectableItemStreamListItem {
final StaggeredTile staggeredTile;
}
mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
mixin SelectableItemStreamListMixin<T extends StatefulWidget>
on State<T>, WidgetsBindingObserver {
@override
initState() {
super.initState();
_keyboardFocus.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
_prevOrientation = MediaQuery.of(context).orientation;
WidgetsBinding.instance.addObserver(this);
});
}
@override
dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
didChangeMetrics() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final orientation = MediaQuery.of(context).orientation;
if (orientation != _prevOrientation) {
_log.info(
"[didChangeMetrics] updateListHeight: orientation changed: $orientation");
_prevOrientation = orientation;
updateListHeight();
}
});
}
Widget buildItemStreamListOuter(
@ -53,9 +78,16 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
}
Widget buildItemStreamList(BuildContext context) {
return SliverStaggeredGrid.extentBuilder(
// need to rebuild grid after cell size changed
key: ValueKey(itemStreamListCellSize),
// need to rebuild grid after cell size changed
final cellSize = itemStreamListCellSize;
if (cellSize != _prevItemStreamListCellSize) {
_log.info("[buildItemStreamList] updateListHeight: cell size changed");
WidgetsBinding.instance.addPostFrameCallback((_) => updateListHeight());
_prevItemStreamListCellSize = cellSize;
}
_gridKey = _GridKey(cellSize);
return _SliverStaggeredGrid.extentBuilder(
key: _gridKey,
maxCrossAxisExtent: itemStreamListCellSize.toDouble(),
itemCount: _items.length,
itemBuilder: _buildItem,
@ -63,11 +95,34 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
);
}
void onMaxExtentChanged(double newExtent) {}
@protected
void clearSelectedItems() {
_selectedItems.clear();
}
@protected
void updateListHeight() {
try {
final renderObj = _gridKey.currentContext.findRenderObject()
as _RenderSliverStaggeredGrid;
final maxExtent = renderObj.calculateExtent();
_log.info("[updateListHeight] Max extent: $maxExtent");
if (maxExtent == 0) {
// ?
_calculatedMaxExtent = null;
} else {
_calculatedMaxExtent = maxExtent;
onMaxExtentChanged(maxExtent);
}
} catch (e, stacktrace) {
_log.shout("[updateListHeight] Failed while calculateMaxScrollExtent", e,
stacktrace);
_calculatedMaxExtent = null;
}
}
@protected
bool get isSelectionMode => _selectedItems.isNotEmpty;
@ -98,11 +153,17 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
}
} catch (_) {}
_lastSelectPosition = newLastSelectPosition;
_log.info("[itemStreamListItems] updateListHeight: list item changed");
WidgetsBinding.instance.addPostFrameCallback((_) => updateListHeight());
}
@protected
int get itemStreamListCellSize;
@protected
double get calculatedMaxExtent => _calculatedMaxExtent;
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
if (item.isSelectable) {
@ -211,10 +272,15 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
int _lastSelectPosition;
bool _isRangeSelectionMode = false;
int _prevItemStreamListCellSize;
double _calculatedMaxExtent;
Orientation _prevOrientation;
final _items = <SelectableItemStreamListItem>[];
final _selectedItems = <SelectableItemStreamListItem>{};
GlobalObjectKey _gridKey;
/// used to gain focus on web for keyboard support
final _keyboardFocus = FocusNode();
@ -222,6 +288,10 @@ mixin SelectableItemStreamListMixin<T extends StatefulWidget> on State<T> {
"widget.selectable_item_stream_list_mixin.SelectableItemStreamListMixin");
}
class _GridKey extends GlobalObjectKey {
const _GridKey(Object value) : super(value);
}
class _SelectableItemWidget extends StatelessWidget {
_SelectableItemWidget({
Key key,
@ -275,3 +345,104 @@ class _SelectableItemWidget extends StatelessWidget {
final VoidCallback onLongPress;
final Widget child;
}
// ignore: must_be_immutable
class _SliverStaggeredGrid extends SliverStaggeredGrid {
_SliverStaggeredGrid.extentBuilder({
Key key,
@required double maxCrossAxisExtent,
@required IndexedStaggeredTileBuilder staggeredTileBuilder,
@required IndexedWidgetBuilder itemBuilder,
@required int itemCount,
double mainAxisSpacing = 0,
double crossAxisSpacing = 0,
}) : super(
key: key,
gridDelegate: SliverStaggeredGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
mainAxisSpacing: mainAxisSpacing,
crossAxisSpacing: crossAxisSpacing,
staggeredTileBuilder: staggeredTileBuilder,
staggeredTileCount: itemCount,
),
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: itemCount,
),
);
@override
RenderSliverStaggeredGrid createRenderObject(BuildContext context) {
final element = context as SliverVariableSizeBoxAdaptorElement;
_renderObject = _RenderSliverStaggeredGrid(
childManager: element, gridDelegate: gridDelegate);
return _renderObject;
}
_RenderSliverStaggeredGrid get renderObject => _renderObject;
_RenderSliverStaggeredGrid _renderObject;
}
class _RenderSliverStaggeredGrid extends RenderSliverStaggeredGrid
with WidgetsBindingObserver {
_RenderSliverStaggeredGrid({
@required RenderSliverVariableSizeBoxChildManager childManager,
@required SliverStaggeredGridDelegate gridDelegate,
}) : super(childManager: childManager, gridDelegate: gridDelegate);
/// Calculate the height of this staggered grid view
///
/// This basically requires a complete layout of every child, so only call
/// when necessary
double calculateExtent() {
childManager.didStartLayout();
childManager.setDidUnderflow(false);
double product = 0;
final configuration = gridDelegate.getConfiguration(constraints);
final mainAxisOffsets = configuration.generateMainAxisOffsets();
// Iterate through all children
for (var index = 0; true; index++) {
var geometry = RenderSliverStaggeredGrid.getSliverStaggeredGeometry(
index, configuration, mainAxisOffsets);
if (geometry == null) {
// There are either no children, or we are past the end of all our children.
break;
}
final bool hasTrailingScrollOffset = geometry.hasTrailingScrollOffset;
RenderBox child;
if (!hasTrailingScrollOffset) {
// Layout the child to compute its tailingScrollOffset.
final constraints =
BoxConstraints.tightFor(width: geometry.crossAxisExtent);
child = addAndLayoutChild(index, constraints, parentUsesSize: true);
geometry = geometry.copyWith(mainAxisExtent: paintExtentOf(child));
}
if (child != null) {
final childParentData =
child.parentData as SliverVariableSizeBoxAdaptorParentData;
childParentData.layoutOffset = geometry.scrollOffset;
childParentData.crossAxisOffset = geometry.crossAxisOffset;
assert(childParentData.index == index);
}
final double endOffset =
geometry.trailingScrollOffset + configuration.mainAxisSpacing;
for (var i = 0; i < geometry.crossAxisCellCount; i++) {
mainAxisOffsets[i + geometry.blockIndex] = endOffset;
}
if (endOffset > product) {
product = endOffset;
}
}
childManager.didFinishLayout();
return product;
}
static final _log = Logger(
"widget.selectable_item_stream_list_mixin._RenderSliverStaggeredGrid");
}

View file

@ -148,6 +148,15 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
draggable_scrollbar:
dependency: "direct main"
description:
path: "."
ref: "v0.1.0-nc-photos-1"
resolved-ref: d97c03ddea7b6bae0e170430e0cb7c27e725ff2a
url: "https://gitlab.com/nkming2/flutter-draggable-scrollbar"
source: git
version: "0.1.0"
equatable:
dependency: "direct main"
description:

View file

@ -31,6 +31,10 @@ dependencies:
bloc: ^7.0.0
cached_network_image: ^3.0.0
connectivity: ^3.0.2
draggable_scrollbar:
git:
url: https://gitlab.com/nkming2/flutter-draggable-scrollbar
ref: v0.1.0-nc-photos-1
equatable: ^2.0.0
event_bus: ^2.0.0
exifdart: