lazy_loading_gallery.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'package:flutter/foundation.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/services.dart';
  6. import 'package:logging/logging.dart';
  7. import 'package:photos/core/constants.dart';
  8. import 'package:photos/events/files_updated_event.dart';
  9. import 'package:photos/models/file.dart';
  10. import 'package:photos/models/selected_files.dart';
  11. import 'package:photos/ui/huge_listview/place_holder_widget.dart';
  12. import 'package:photos/ui/viewer/file/detail_page.dart';
  13. import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
  14. import 'package:photos/ui/viewer/gallery/gallery.dart';
  15. import 'package:photos/utils/date_time_util.dart';
  16. import 'package:photos/utils/navigation_util.dart';
  17. import 'package:visibility_detector/visibility_detector.dart';
  18. class LazyLoadingGallery extends StatefulWidget {
  19. final List<File> files;
  20. final int index;
  21. final Stream<FilesUpdatedEvent> reloadEvent;
  22. final Set<EventType> removalEventTypes;
  23. final GalleryLoader asyncLoader;
  24. final SelectedFiles selectedFiles;
  25. final String tag;
  26. final Stream<int> currentIndexStream;
  27. final bool smallerTodayFont;
  28. LazyLoadingGallery(
  29. this.files,
  30. this.index,
  31. this.reloadEvent,
  32. this.removalEventTypes,
  33. this.asyncLoader,
  34. this.selectedFiles,
  35. this.tag,
  36. this.currentIndexStream, {
  37. this.smallerTodayFont,
  38. Key key,
  39. }) : super(key: key ?? UniqueKey());
  40. @override
  41. State<LazyLoadingGallery> createState() => _LazyLoadingGalleryState();
  42. }
  43. class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
  44. static const kSubGalleryItemLimit = 80;
  45. static const kRecycleLimit = 400;
  46. static const kNumberOfDaysToRenderBeforeAndAfter = 8;
  47. static final Logger _logger = Logger("LazyLoadingGallery");
  48. List<File> _files;
  49. StreamSubscription<FilesUpdatedEvent> _reloadEventSubscription;
  50. StreamSubscription<int> _currentIndexSubscription;
  51. bool _shouldRender;
  52. @override
  53. void initState() {
  54. super.initState();
  55. _init();
  56. }
  57. void _init() {
  58. _shouldRender = true;
  59. _files = widget.files;
  60. _reloadEventSubscription = widget.reloadEvent.listen((e) => _onReload(e));
  61. _currentIndexSubscription =
  62. widget.currentIndexStream.listen((currentIndex) {
  63. final bool shouldRender = (currentIndex - widget.index).abs() <
  64. kNumberOfDaysToRenderBeforeAndAfter;
  65. if (mounted && shouldRender != _shouldRender) {
  66. setState(() {
  67. _shouldRender = shouldRender;
  68. });
  69. }
  70. });
  71. }
  72. Future _onReload(FilesUpdatedEvent event) async {
  73. final galleryDate =
  74. DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime);
  75. final filesUpdatedThisDay = event.updatedFiles.where((file) {
  76. final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime);
  77. return fileDate.year == galleryDate.year &&
  78. fileDate.month == galleryDate.month &&
  79. fileDate.day == galleryDate.day;
  80. });
  81. if (filesUpdatedThisDay.isNotEmpty) {
  82. _logger.info(
  83. filesUpdatedThisDay.length.toString() +
  84. " files were updated on " +
  85. getDayTitle(galleryDate.microsecondsSinceEpoch),
  86. );
  87. if (event.type == EventType.addedOrUpdated) {
  88. final dayStartTime =
  89. DateTime(galleryDate.year, galleryDate.month, galleryDate.day);
  90. final result = await widget.asyncLoader(
  91. dayStartTime.microsecondsSinceEpoch,
  92. dayStartTime.microsecondsSinceEpoch + kMicroSecondsInDay - 1,
  93. );
  94. if (mounted) {
  95. setState(() {
  96. _files = result.files;
  97. });
  98. }
  99. } else if (widget.removalEventTypes.contains(event.type)) {
  100. // Files were removed
  101. final updateFileIDs = <int>{};
  102. for (final file in filesUpdatedThisDay) {
  103. updateFileIDs.add(file.generatedID);
  104. }
  105. final List<File> files = [];
  106. files.addAll(_files);
  107. files.removeWhere((file) => updateFileIDs.contains(file.generatedID));
  108. if (mounted) {
  109. setState(() {
  110. _files = files;
  111. });
  112. }
  113. }
  114. }
  115. }
  116. @override
  117. void dispose() {
  118. _reloadEventSubscription.cancel();
  119. _currentIndexSubscription.cancel();
  120. super.dispose();
  121. }
  122. @override
  123. void didUpdateWidget(LazyLoadingGallery oldWidget) {
  124. super.didUpdateWidget(oldWidget);
  125. if (!listEquals(_files, widget.files)) {
  126. _reloadEventSubscription.cancel();
  127. _init();
  128. }
  129. }
  130. @override
  131. Widget build(BuildContext context) {
  132. if (_files.isEmpty) {
  133. return const SizedBox.shrink();
  134. }
  135. return Padding(
  136. padding: const EdgeInsets.only(bottom: 12),
  137. child: Column(
  138. children: [
  139. getDayWidget(
  140. context,
  141. _files[0].creationTime,
  142. widget.smallerTodayFont,
  143. ),
  144. _shouldRender ? _getGallery() : PlaceHolderWidget(_files.length),
  145. ],
  146. ),
  147. );
  148. }
  149. Widget _getGallery() {
  150. final List<Widget> childGalleries = [];
  151. for (int index = 0; index < _files.length; index += kSubGalleryItemLimit) {
  152. childGalleries.add(
  153. LazyLoadingGridView(
  154. widget.tag,
  155. _files.sublist(
  156. index,
  157. min(index + kSubGalleryItemLimit, _files.length),
  158. ),
  159. widget.asyncLoader,
  160. widget.selectedFiles,
  161. index == 0,
  162. _files.length > kRecycleLimit,
  163. ),
  164. );
  165. }
  166. return Column(
  167. children: childGalleries,
  168. );
  169. }
  170. }
  171. class LazyLoadingGridView extends StatefulWidget {
  172. final String tag;
  173. final List<File> files;
  174. final GalleryLoader asyncLoader;
  175. final SelectedFiles selectedFiles;
  176. final bool shouldRender;
  177. final bool shouldRecycle;
  178. LazyLoadingGridView(
  179. this.tag,
  180. this.files,
  181. this.asyncLoader,
  182. this.selectedFiles,
  183. this.shouldRender,
  184. this.shouldRecycle, {
  185. Key key,
  186. }) : super(key: key ?? UniqueKey());
  187. @override
  188. State<LazyLoadingGridView> createState() => _LazyLoadingGridViewState();
  189. }
  190. class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
  191. bool _shouldRender;
  192. @override
  193. void initState() {
  194. super.initState();
  195. _shouldRender = widget.shouldRender;
  196. widget.selectedFiles.addListener(() {
  197. bool shouldRefresh = false;
  198. for (final file in widget.files) {
  199. if (widget.selectedFiles.isPartOfLastSection(file)) {
  200. shouldRefresh = true;
  201. }
  202. }
  203. if (shouldRefresh && mounted) {
  204. setState(() {});
  205. }
  206. });
  207. }
  208. @override
  209. void didUpdateWidget(LazyLoadingGridView oldWidget) {
  210. super.didUpdateWidget(oldWidget);
  211. if (!listEquals(widget.files, oldWidget.files)) {
  212. _shouldRender = widget.shouldRender;
  213. }
  214. }
  215. @override
  216. Widget build(BuildContext context) {
  217. if (widget.shouldRecycle) {
  218. return _getRecyclableView();
  219. } else {
  220. return _getNonRecyclableView();
  221. }
  222. }
  223. Widget _getRecyclableView() {
  224. return VisibilityDetector(
  225. key: UniqueKey(),
  226. onVisibilityChanged: (visibility) {
  227. final shouldRender = visibility.visibleFraction > 0;
  228. if (mounted && shouldRender != _shouldRender) {
  229. setState(() {
  230. _shouldRender = shouldRender;
  231. });
  232. }
  233. },
  234. child: _shouldRender
  235. ? _getGridView()
  236. : PlaceHolderWidget(widget.files.length),
  237. );
  238. }
  239. Widget _getNonRecyclableView() {
  240. if (!_shouldRender) {
  241. return VisibilityDetector(
  242. key: UniqueKey(),
  243. onVisibilityChanged: (visibility) {
  244. if (mounted && visibility.visibleFraction > 0 && !_shouldRender) {
  245. setState(() {
  246. _shouldRender = true;
  247. });
  248. }
  249. },
  250. child: PlaceHolderWidget(widget.files.length),
  251. );
  252. } else {
  253. return _getGridView();
  254. }
  255. }
  256. Widget _getGridView() {
  257. return GridView.builder(
  258. shrinkWrap: true,
  259. physics:
  260. const NeverScrollableScrollPhysics(), // to disable GridView's scrolling
  261. itemBuilder: (context, index) {
  262. return _buildFile(context, widget.files[index]);
  263. },
  264. itemCount: widget.files.length,
  265. gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
  266. crossAxisCount: 4,
  267. ),
  268. padding: const EdgeInsets.all(0),
  269. );
  270. }
  271. Widget _buildFile(BuildContext context, File file) {
  272. return GestureDetector(
  273. onTap: () {
  274. if (widget.selectedFiles.files.isNotEmpty) {
  275. _selectFile(file);
  276. } else {
  277. _routeToDetailPage(file, context);
  278. }
  279. },
  280. onLongPress: () {
  281. HapticFeedback.lightImpact();
  282. _selectFile(file);
  283. },
  284. child: Container(
  285. margin: const EdgeInsets.all(1.5),
  286. decoration: BoxDecoration(
  287. borderRadius: BorderRadius.circular(3),
  288. ),
  289. child: ClipRRect(
  290. borderRadius: BorderRadius.circular(3),
  291. child: Stack(
  292. children: [
  293. Hero(
  294. tag: widget.tag + file.tag(),
  295. child: ColorFiltered(
  296. colorFilter: ColorFilter.mode(
  297. Colors.black.withOpacity(
  298. widget.selectedFiles.isFileSelected(file) ? 0.4 : 0,
  299. ),
  300. BlendMode.darken,
  301. ),
  302. child: ThumbnailWidget(
  303. file,
  304. diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration,
  305. serverLoadDeferDuration: kThumbnailServerLoadDeferDuration,
  306. shouldShowLivePhotoOverlay: true,
  307. key: Key(widget.tag + file.tag()),
  308. ),
  309. ),
  310. ),
  311. Visibility(
  312. visible: widget.selectedFiles.isFileSelected(file),
  313. child: const Positioned(
  314. right: 4,
  315. top: 4,
  316. child: Icon(
  317. Icons.check_circle_rounded,
  318. size: 20,
  319. color: Colors.white, //same for both themes
  320. ),
  321. ),
  322. )
  323. ],
  324. ),
  325. ),
  326. ),
  327. );
  328. }
  329. void _selectFile(File file) {
  330. widget.selectedFiles.toggleSelection(file);
  331. }
  332. void _routeToDetailPage(File file, BuildContext context) {
  333. final page = DetailPage(
  334. DetailPageConfiguration(
  335. List.unmodifiable(widget.files),
  336. widget.asyncLoader,
  337. widget.files.indexOf(file),
  338. widget.tag,
  339. ),
  340. );
  341. routeToPage(context, page, forceCustomPageRoute: true);
  342. }
  343. }