diff --git a/lib/widget/image_viewer.dart b/lib/widget/image_viewer.dart new file mode 100644 index 00000000..d7b76489 --- /dev/null +++ b/lib/widget/image_viewer.dart @@ -0,0 +1,219 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod; + +class ImageViewer extends StatefulWidget { + ImageViewer({ + @required this.account, + @required this.file, + this.canZoom, + this.onLoaded, + this.onHeightChanged, + this.onZoomStarted, + this.onZoomEnded, + }); + + @override + createState() => _ImageViewerState(); + + static void preloadImage(Account account, File file) { + DefaultCacheManager().getFileStream( + _getImageUrl(account, file), + headers: { + "Authorization": Api.getAuthorizationHeaderValue(account), + }, + ); + } + + final Account account; + final File file; + final bool canZoom; + final VoidCallback onLoaded; + final void Function(double height) onHeightChanged; + final VoidCallback onZoomStarted; + final VoidCallback onZoomEnded; +} + +class _ImageViewerState extends State + with TickerProviderStateMixin { + @override + build(BuildContext context) { + final content = InteractiveViewer( + minScale: 1.0, + maxScale: 3.0, + transformationController: _transformationController, + panEnabled: widget.canZoom, + scaleEnabled: widget.canZoom, + // allow the image to be zoomed to fill the whole screen + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + alignment: Alignment.center, + child: NotificationListener( + onNotification: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_key.currentContext != null) { + widget.onHeightChanged?.call(_key.currentContext.size.height); + } + }); + return false; + }, + child: SizeChangedLayoutNotifier( + child: mod.CachedNetworkImage( + key: _key, + imageUrl: _getImageUrl(widget.account, widget.file), + httpHeaders: { + "Authorization": + Api.getAuthorizationHeaderValue(widget.account), + }, + fit: BoxFit.contain, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + imageBuilder: (context, child, imageProvider) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _onItemLoaded(); + }); + SizeChangedLayoutNotification().dispatch(context); + return child; + }, + ), + ), + ), + ), + ); + if (widget.canZoom) { + return Listener( + onPointerDown: (_) { + ++_finger; + if (_finger >= 2) { + _setIsZooming(true); + } + }, + onPointerUp: (event) { + --_finger; + if (_finger < 2) { + _setIsZooming(false); + } + _prevFingerPosition = event.position; + }, + child: GestureDetector( + onDoubleTap: () { + if (_isZoomed) { + // restore transformation + _autoZoomOut(); + } else { + _autoZoomIn(); + } + }, + child: content, + ), + ); + } else { + return content; + } + } + + @override + dispose() { + super.dispose(); + _transformationController.dispose(); + } + + void _onItemLoaded() { + if (!_isLoaded) { + _log.info("[_onItemLoaded]"); + _isLoaded = true; + widget.onLoaded?.call(); + } + } + + void _setIsZooming(bool flag) { + _isZooming = flag; + final next = _isZoomed; + if (next != _wasZoomed) { + _wasZoomed = next; + _log.info("[_setIsZooming] Is zoomed: $next"); + if (next) { + widget.onZoomStarted?.call(); + } else { + widget.onZoomEnded?.call(); + } + } + } + + bool get _isZoomed => + _isZooming || _transformationController.value.getMaxScaleOnAxis() != 1.0; + + /// Called when double tapping the image to zoom in to the default level + void _autoZoomIn() { + final animController = + AnimationController(duration: k.animationDurationShort, vsync: this); + final originX = -_prevFingerPosition.dx / 2; + final originY = -_prevFingerPosition.dy / 2; + final anim = Matrix4Tween( + begin: Matrix4.identity(), + end: Matrix4.identity() + ..scale(2.0) + ..translate(originX, originY)) + .animate(animController); + animController + ..addListener(() { + _transformationController.value = anim.value; + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _setIsZooming(false); + } + }) + ..forward(); + _setIsZooming(true); + } + + /// Called when double tapping the zoomed image to zoom out + void _autoZoomOut() { + final animController = + AnimationController(duration: k.animationDurationShort, vsync: this); + final anim = Matrix4Tween( + begin: _transformationController.value, end: Matrix4.identity()) + .animate(animController); + animController + ..addListener(() { + _transformationController.value = anim.value; + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _setIsZooming(false); + } + }) + ..forward(); + _setIsZooming(true); + } + + final _key = GlobalKey(); + final _transformationController = TransformationController(); + + var _isLoaded = false; + var _isZooming = false; + var _wasZoomed = false; + + int _finger = 0; + var _prevFingerPosition = Offset(0, 0); + + static final _log = Logger("widget.image_viewer._ImageViewerState"); +} + +String _getImageUrl(Account account, File file) => api_util.getFilePreviewUrl( + account, + file, + width: 1080, + height: 1080, + a: true, + ); diff --git a/lib/widget/viewer.dart b/lib/widget/viewer.dart index bb323cf9..642149bd 100644 --- a/lib/widget/viewer.dart +++ b/lib/widget/viewer.dart @@ -1,16 +1,12 @@ import 'dart:math'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/api/api.dart'; -import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/exception.dart'; @@ -23,7 +19,7 @@ import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/remove.dart'; -import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod; +import 'package:nc_photos/widget/image_viewer.dart'; import 'package:nc_photos/widget/viewer_detail_pane.dart'; class ViewerArguments { @@ -60,7 +56,7 @@ class Viewer extends StatefulWidget { final int startIndex; } -class _ViewerState extends State with TickerProviderStateMixin { +class _ViewerState extends State { @override void initState() { super.initState(); @@ -107,58 +103,33 @@ class _ViewerState extends State with TickerProviderStateMixin { } Widget _buildContent(BuildContext context) { - return Listener( - onPointerDown: (event) { - ++_finger; - if (_finger >= 2 && _canZoom()) { - _setIsZooming(true); - } + return GestureDetector( + onTap: () { + setState(() { + _setShowActionBar(!_isShowAppBar); + }); }, - onPointerUp: (event) { - --_finger; - if (_finger < 2) { - _setIsZooming(false); - } - _prevFingerPosition = event.position; - }, - child: GestureDetector( - onTap: () { - setState(() { - _setShowActionBar(!_isShowAppBar); - }); - }, - onDoubleTap: () { - if (_canZoom()) { - if (_isZoomed()) { - // restore transformation - _autoZoomOut(); - } else { - _autoZoomIn(); - } - } - }, - child: Stack( - children: [ - Container(color: Colors.black), - if (!_pageController.hasClients || - !_pageStates[_pageController.page.round()].hasPreloaded) - Align( - alignment: Alignment.center, - child: const CircularProgressIndicator(), - ), - PageView.builder( - controller: _pageController, - itemCount: widget.streamFiles.length, - itemBuilder: _buildPage, - physics: !platform_k.isWeb && _canSwitchPage() - ? null - : const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + Container(color: Colors.black), + if (!_pageController.hasClients || + !_pageStates[_pageController.page.round()].hasLoaded) + Align( + alignment: Alignment.center, + child: const CircularProgressIndicator(), ), - if (platform_k.isWeb) ..._buildNavigationButtons(context), - _buildBottomAppBar(context), - _buildAppBar(context), - ], - ), + PageView.builder( + controller: _pageController, + itemCount: widget.streamFiles.length, + itemBuilder: _buildPage, + physics: !platform_k.isWeb && _canSwitchPage() + ? null + : const NeverScrollableScrollPhysics(), + ), + if (platform_k.isWeb) ..._buildNavigationButtons(context), + _buildBottomAppBar(context), + _buildAppBar(context), + ], ), ); } @@ -413,50 +384,22 @@ class _ViewerState extends State with TickerProviderStateMixin { } Widget _buildItemView(BuildContext context, int index) { - return InteractiveViewer( - minScale: 1.0, - maxScale: 3.0, - transformationController: _transformationController, - panEnabled: _canZoom(), - scaleEnabled: _canZoom(), - // allow the image to be zoomed to fill the whole screen - child: Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - alignment: Alignment.center, - child: NotificationListener( - onNotification: (_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_pageStates[index].key.currentContext != null) { - _updateItemHeight( - index, _pageStates[index].key.currentContext.size.height); - } - }); - return false; - }, - child: SizeChangedLayoutNotifier( - child: mod.CachedNetworkImage( - key: _pageStates[index].key, - imageUrl: _getImageUrl(widget.account, widget.streamFiles[index]), - httpHeaders: { - "Authorization": - Api.getAuthorizationHeaderValue(widget.account), - }, - fit: BoxFit.contain, - fadeInDuration: const Duration(), - filterQuality: FilterQuality.high, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - imageBuilder: (context, child, imageProvider) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _onItemLoaded(index); - }); - SizeChangedLayoutNotification().dispatch(context); - return child; - }, - ), - ), - ), - ), + return ImageViewer( + account: widget.account, + file: widget.streamFiles[index], + canZoom: _canZoom(), + onLoaded: () => _onImageLoaded(index), + onHeightChanged: (height) => _updateItemHeight(index, height), + onZoomStarted: () { + setState(() { + _isZoomed = true; + }); + }, + onZoomEnded: () { + setState(() { + _isZoomed = false; + }); + }, ); } @@ -491,30 +434,22 @@ class _ViewerState extends State with TickerProviderStateMixin { return false; } - void _onItemLoaded(int index) { + void _onImageLoaded(int index) { // currently pageview doesn't pre-load pages, we do it manually // don't pre-load if user already navigated away if (_pageController.page.round() == index && - !_pageStates[index].hasPreloaded) { - _log.info("[_onItemLoaded] Pre-loading nearby items"); + !_pageStates[index].hasLoaded) { + _log.info("[_onImageLoaded] Pre-loading nearby images"); if (index > 0) { - DefaultCacheManager().getFileStream( - _getImageUrl(widget.account, widget.streamFiles[index - 1]), - headers: { - "Authorization": Api.getAuthorizationHeaderValue(widget.account), - }, - ); + final prevFile = widget.streamFiles[index - 1]; + ImageViewer.preloadImage(widget.account, prevFile); } if (index + 1 < widget.streamFiles.length) { - DefaultCacheManager().getFileStream( - _getImageUrl(widget.account, widget.streamFiles[index + 1]), - headers: { - "Authorization": Api.getAuthorizationHeaderValue(widget.account), - }, - ); + final nextFile = widget.streamFiles[index + 1]; + ImageViewer.preloadImage(widget.account, nextFile); } setState(() { - _pageStates[index].hasPreloaded = true; + _pageStates[index].hasLoaded = true; }); } } @@ -721,67 +656,6 @@ class _ViewerState extends State with TickerProviderStateMixin { _isClosingDetailPane = false; } - void _setIsZooming(bool flag) { - _isZooming = flag; - final next = _isZoomed(); - if (next != _wasZoomed) { - _wasZoomed = next; - setState(() { - _log.info("[_setIsZooming] Is zoomed: $next"); - }); - } - } - - bool _isZoomed() { - return _isZooming || - _transformationController.value.getMaxScaleOnAxis() != 1.0; - } - - /// Called when double tapping the image to zoom in to the default level - void _autoZoomIn() { - final animController = - AnimationController(duration: k.animationDurationShort, vsync: this); - final originX = -_prevFingerPosition.dx / 2; - final originY = -_prevFingerPosition.dy / 2; - final anim = Matrix4Tween( - begin: Matrix4.identity(), - end: Matrix4.identity() - ..scale(2.0) - ..translate(originX, originY)) - .animate(animController); - animController - ..addListener(() { - _transformationController.value = anim.value; - }) - ..addStatusListener((status) { - if (status == AnimationStatus.completed) { - _setIsZooming(false); - } - }) - ..forward(); - _setIsZooming(true); - } - - /// Called when double tapping the zoomed image to zoom out - void _autoZoomOut() { - final animController = - AnimationController(duration: k.animationDurationShort, vsync: this); - final anim = Matrix4Tween( - begin: _transformationController.value, end: Matrix4.identity()) - .animate(animController); - animController - ..addListener(() { - _transformationController.value = anim.value; - }) - ..addStatusListener((status) { - if (status == AnimationStatus.completed) { - _setIsZooming(false); - } - }) - ..forward(); - _setIsZooming(true); - } - /// Switch to the previous image in the stream void _switchToPrevImage() { _pageController @@ -849,18 +723,10 @@ class _ViewerState extends State with TickerProviderStateMixin { } } - bool _canSwitchPage() => !_isZoomed(); - bool _canOpenDetailPane() => !_isZoomed(); + bool _canSwitchPage() => !_isZoomed; + bool _canOpenDetailPane() => !_isZoomed; bool _canZoom() => !_isDetailPaneActive; - String _getImageUrl(Account account, File file) => api_util.getFilePreviewUrl( - account, - file, - width: 1080, - height: 1080, - a: true, - ); - var _hasInit = false; var _isShowAppBar = true; @@ -875,12 +741,7 @@ class _ViewerState extends State with TickerProviderStateMixin { var _isShowRight = false; var _isShowLeft = false; - var _isZooming = false; - var _wasZoomed = false; - final _transformationController = TransformationController(); - - int _finger = 0; - Offset _prevFingerPosition; + var _isZoomed = false; PageController _pageController; final _pageStates = {}; @@ -896,6 +757,5 @@ class _PageState { ScrollController scrollController; double itemHeight; - bool hasPreloaded = false; - GlobalKey key = GlobalKey(); + bool hasLoaded = false; }