aves/lib/widgets/common/thumbnail/scroller.dart
Thibault Deckers fd33904658 map browse prep
2021-08-18 18:48:18 +09:00

152 lines
4.7 KiB
Dart

import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ThumbnailScroller extends StatefulWidget {
final double availableWidth;
final int entryCount;
final AvesEntry? Function(int index) entryBuilder;
final int? initialIndex;
final bool Function(int page) isCurrentIndex;
final void Function(int index) onIndexChange;
const ThumbnailScroller({
Key? key,
required this.availableWidth,
required this.entryCount,
required this.entryBuilder,
required this.initialIndex,
required this.isCurrentIndex,
required this.onIndexChange,
}) : super(key: key);
@override
_ThumbnailScrollerState createState() => _ThumbnailScrollerState();
}
class _ThumbnailScrollerState extends State<ThumbnailScroller> {
final _cancellableNotifier = ValueNotifier(true);
late ScrollController _scrollController;
bool _syncScroll = true;
static const double extent = 48;
static const double separatorWidth = 2;
int get entryCount => widget.entryCount;
@override
void initState() {
super.initState();
_registerWidget();
}
@override
void didUpdateWidget(covariant ThumbnailScroller oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialIndex != widget.initialIndex) {
_unregisterWidget();
_registerWidget();
}
}
@override
void dispose() {
_unregisterWidget();
super.dispose();
}
void _registerWidget() {
final scrollOffset = indexToScrollOffset(widget.initialIndex ?? 0);
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange);
}
void _unregisterWidget() {
_scrollController.removeListener(_onScrollChange);
_scrollController.dispose();
}
@override
Widget build(BuildContext context) {
final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth);
final horizontalMargin = SizedBox(width: marginWidth);
const separator = SizedBox(width: separatorWidth);
return GridTheme(
extent: extent,
showLocation: false,
child: SizedBox(
height: extent,
child: ListView.separated(
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == entryCount + 1) return horizontalMargin;
final page = index - 1;
final pageEntry = widget.entryBuilder(page);
if (pageEntry == null) return const SizedBox();
return Stack(
children: [
GestureDetector(
onTap: () => _goTo(page),
child: DecoratedThumbnail(
entry: pageEntry,
tileExtent: extent,
// the retrieval task queue can pile up for thumbnails of heavy pages
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
// so we cancel these requests when possible
cancellableNotifier: _cancellableNotifier,
selectable: false,
highlightable: false,
hero: false,
),
),
IgnorePointer(
child: AnimatedContainer(
color: widget.isCurrentIndex(page) ? Colors.transparent : Colors.black45,
width: extent,
height: extent,
duration: Durations.thumbnailScrollerShadeAnimation,
),
)
],
);
},
separatorBuilder: (context, index) => separator,
itemCount: entryCount + 2,
),
),
);
}
Future<void> _goTo(int index) async {
_syncScroll = false;
widget.onIndexChange(index);
await _scrollController.animateTo(
indexToScrollOffset(index),
duration: Durations.thumbnailScrollerScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
}
void _onScrollChange() {
if (_syncScroll) {
widget.onIndexChange(scrollOffsetToIndex(_scrollController.offset));
}
}
double indexToScrollOffset(int index) => index * (extent + separatorWidth);
int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round();
}