From 4c3da1ccb25e9ec10089971e450ec080e341ac0d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 7 Nov 2023 22:49:44 +0900 Subject: [PATCH 001/141] add: MediaViewerPageControlBar.Item --- .../MediaViewerPageControlBar.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index ccf2190e..9157d8c5 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -24,6 +24,41 @@ protocol MediaViewerPageControlBarDataSource: AnyObject { final class MediaViewerPageControlBar: UIView { + /// An item of the page control bar. + /// + /// This is used as an item of the diffable data source so that + /// the thumbnail does not need to be reloaded when the viewer's page count changes. + struct Item: Hashable, Identifiable, Sendable { + + /// An ID of the item. + /// + /// Only this value is used to compare for equality and calculate hash value. + let id = UUID() + + /// A page of the media viewer. + /// + /// This value always matches the actual page number so it may change + /// while the ID remains the same. + /// For example, when some media is deleted, the page number after the deleted media is + /// carried down by one. + /// + /// ```swift + /// [(ID_A, 0), (ID_B, 1), (ID_C, 2), (ID_D, 3)] + /// // ↓ Delete (ID_B, 1) + /// [(ID_A, 0), (ID_C, 1), (ID_D, 2)] + /// ``` + var page: Int + + @available(*, deprecated, message: "Compare id or page directly") + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + enum State: Hashable, Sendable { case collapsing From 76e1fd7995583fd0943665b3d0304a5f02a47b72 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 02:35:20 +0900 Subject: [PATCH 002/141] change: replace page number with MediaViewerPageControlBar.Item --- .../MediaViewerPageControlBar.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 9157d8c5..e78e1b2e 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -89,7 +89,7 @@ final class MediaViewerPageControlBar: UIView { private typealias CellRegistration = UICollectionView.CellRegistration< PageControlBarThumbnailCell, - Int // page + Item > weak var dataSource: (any MediaViewerPageControlBarDataSource)? @@ -144,18 +144,18 @@ final class MediaViewerPageControlBar: UIView { return collectionView }() - lazy var diffableDataSource = UICollectionViewDiffableDataSource( + lazy var diffableDataSource = UICollectionViewDiffableDataSource( collectionView: collectionView - ) { [weak self] collectionView, indexPath, page in + ) { [weak self] collectionView, indexPath, item in guard let self else { return nil } return collectionView.dequeueConfiguredReusableCell( using: cellRegistration, for: indexPath, - item: page + item: item ) } - private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, page in + private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, item in guard let self, let dataSource else { return } let scale = window?.screen.scale ?? 3 let preferredSize = CGSize( @@ -164,7 +164,7 @@ final class MediaViewerPageControlBar: UIView { ) let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailOnPage: page, + thumbnailOnPage: item.page, filling: preferredSize ) cell.configure(with: thumbnailSource) @@ -227,9 +227,9 @@ final class MediaViewerPageControlBar: UIView { // MARK: - Methods func configure(numberOfPages: Int, currentPage: Int) { - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems(Array(0.. Date: Wed, 8 Nov 2023 02:39:59 +0900 Subject: [PATCH 003/141] add: item(forPage:) and cell(for:) --- .../MediaViewerPageControlBar.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index e78e1b2e..045cc80c 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -241,6 +241,24 @@ final class MediaViewerPageControlBar: UIView { } } + private func item(forPage page: Int) -> Item { + let item = diffableDataSource.snapshot().itemIdentifiers[page] + precondition(item.page == page) + return item + } + + private func cell(for item: Item) -> PageControlBarThumbnailCell? { + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) else { + return nil + } + guard let cell = cell as? PageControlBarThumbnailCell else { + assertionFailure("Unexpected cell: \(cell)") + return nil + } + return cell + } + private func updateLayout( expandingItemAt indexPath: IndexPath?, expandingThumbnailWidthToHeight: CGFloat? = nil, From 89fe4b05339ce5c27f7246d8014055db049b5c8a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 02:42:44 +0900 Subject: [PATCH 004/141] delete: MediaViewerPageControlBar.Item.page # Conflicts: # Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift --- .../MediaViewerPageControlBar.swift | 43 +++---------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 045cc80c..d222f991 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -24,39 +24,8 @@ protocol MediaViewerPageControlBarDataSource: AnyObject { final class MediaViewerPageControlBar: UIView { - /// An item of the page control bar. - /// - /// This is used as an item of the diffable data source so that - /// the thumbnail does not need to be reloaded when the viewer's page count changes. struct Item: Hashable, Identifiable, Sendable { - - /// An ID of the item. - /// - /// Only this value is used to compare for equality and calculate hash value. let id = UUID() - - /// A page of the media viewer. - /// - /// This value always matches the actual page number so it may change - /// while the ID remains the same. - /// For example, when some media is deleted, the page number after the deleted media is - /// carried down by one. - /// - /// ```swift - /// [(ID_A, 0), (ID_B, 1), (ID_C, 2), (ID_D, 3)] - /// // ↓ Delete (ID_B, 1) - /// [(ID_A, 0), (ID_C, 1), (ID_D, 2)] - /// ``` - var page: Int - - @available(*, deprecated, message: "Compare id or page directly") - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } } enum State: Hashable, Sendable { @@ -164,7 +133,7 @@ final class MediaViewerPageControlBar: UIView { ) let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailOnPage: item.page, + thumbnailOnPage: page(of: item)!, filling: preferredSize ) cell.configure(with: thumbnailSource) @@ -229,7 +198,7 @@ final class MediaViewerPageControlBar: UIView { func configure(numberOfPages: Int, currentPage: Int) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems((0.. Int? { + diffableDataSource.snapshot().indexOfItem(item) + } + private func item(forPage page: Int) -> Item { - let item = diffableDataSource.snapshot().itemIdentifiers[page] - precondition(item.page == page) - return item + diffableDataSource.snapshot().itemIdentifiers[page] } private func cell(for item: Item) -> PageControlBarThumbnailCell? { From 014a0a4cf6d65a86b03d6416b77a17973fc55a7f Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 21:23:59 +0900 Subject: [PATCH 005/141] add: MediaViewerPageID --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 36b04b23..fe2b3d11 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -8,6 +8,11 @@ import UIKit import Combine +/// An identifier of the media viewer page. +struct MediaViewerPageID: Hashable, Sendable { + let rawValue = UUID() +} + /// An media viewer. /// /// It is recommended to set your `MediaViewerViewController` instance to `navigationController?.delegate` to enable smooth transition animation. From f24bea3562e6b182feef08f5dc905c9936f0662f Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 21:26:01 +0900 Subject: [PATCH 006/141] change: MediaViewerPageControlBar.Item to MediaViewerPageID --- .../PageControlBar/MediaViewerPageControlBar.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index d222f991..64e8c75e 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -24,9 +24,7 @@ protocol MediaViewerPageControlBarDataSource: AnyObject { final class MediaViewerPageControlBar: UIView { - struct Item: Hashable, Identifiable, Sendable { - let id = UUID() - } + typealias Item = MediaViewerPageID enum State: Hashable, Sendable { case collapsing From 8b6a5b264bd6407764985ad852ad642be5efcb4c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 21:32:18 +0900 Subject: [PATCH 007/141] add: MediaViewerVM.pageIDs --- Sources/MediaViewer/MediaViewerViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 2c7bffe8..f7bbd45e 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -8,5 +8,11 @@ import Combine final class MediaViewerViewModel: ObservableObject { + + /// Page identifiers of the media viewer. + /// + /// The page number corresponds to the index of this array. + @Published var pageIDs: [MediaViewerPageID] = [] + @Published var showsMediaOnly = false } From 36fff082847dab29c2c1c60db2be404ce62010d4 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 21:33:09 +0900 Subject: [PATCH 008/141] add: MediaViewerVM.setUpPageIDs --- Sources/MediaViewer/MediaViewerViewController.swift | 3 +++ Sources/MediaViewer/MediaViewerViewModel.swift | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index fe2b3d11..a9a5012a 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -129,6 +129,9 @@ open class MediaViewerViewController: UIPageViewController { ) mediaViewerDataSource = dataSource + let numberOfMedia = dataSource.numberOfMedia(in: self) + mediaViewerVM.setUpPageIDs(numberOfMedia: numberOfMedia) + guard let mediaViewerPage = makeMediaViewerPage(forPage: page) else { preconditionFailure("Page \(page) out of range.") } diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index f7bbd45e..58369956 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -15,4 +15,8 @@ final class MediaViewerViewModel: ObservableObject { @Published var pageIDs: [MediaViewerPageID] = [] @Published var showsMediaOnly = false + + func setUpPageIDs(numberOfMedia: Int) { + pageIDs = (0.. Date: Thu, 9 Nov 2023 21:34:07 +0900 Subject: [PATCH 009/141] change: MediaViewerPageControlBar.configure(...) --- Sources/MediaViewer/MediaViewerViewController.swift | 8 ++++---- .../PageControlBar/MediaViewerPageControlBar.swift | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index a9a5012a..ba43fd5c 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -187,10 +187,10 @@ open class MediaViewerViewController: UIPageViewController { view.insertSubview(backgroundView, at: 0) view.addSubview(pageControlToolbar) - if let mediaViewerDataSource { - let numberOfPages = mediaViewerDataSource.numberOfMedia(in: self) - pageControlBar.configure(numberOfPages: numberOfPages, currentPage: currentPage) - } + pageControlBar.configure( + pageIDs: mediaViewerVM.pageIDs, + currentPage: currentPage + ) pageControlToolbar.addSubview(pageControlBar) // Layout diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 64e8c75e..a45cb717 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -193,10 +193,10 @@ final class MediaViewerPageControlBar: UIView { // MARK: - Methods - func configure(numberOfPages: Int, currentPage: Int) { - var snapshot = NSDiffableDataSourceSnapshot() + func configure(pageIDs: [MediaViewerPageID], currentPage: Int) { + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems((0.. Date: Thu, 9 Nov 2023 21:52:42 +0900 Subject: [PATCH 010/141] add: page-related methods to MediaViewerVM --- Sources/MediaViewer/MediaViewerViewModel.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 58369956..4e95e626 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -16,7 +16,18 @@ final class MediaViewerViewModel: ObservableObject { @Published var showsMediaOnly = false + // MARK: - Methods + func setUpPageIDs(numberOfMedia: Int) { pageIDs = (0.. MediaViewerPageID? { + guard 0 <= page && page < pageIDs.endIndex else { return nil } + return pageIDs[page] + } + + func page(with pageID: MediaViewerPageID) -> Int? { + pageIDs.firstIndex(of: pageID) + } } From b9abdf249f74745496062f0f1d2277ed7f2b36d1 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 22:04:32 +0900 Subject: [PATCH 011/141] change: makeMediaViewerPage(forPage:) => makeMediaViewerPage(with:) --- .../MediaViewerViewController.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index ba43fd5c..2869b0a2 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -132,7 +132,8 @@ open class MediaViewerViewController: UIPageViewController { let numberOfMedia = dataSource.numberOfMedia(in: self) mediaViewerVM.setUpPageIDs(numberOfMedia: numberOfMedia) - guard let mediaViewerPage = makeMediaViewerPage(forPage: page) else { + guard let pageID = mediaViewerVM.pageID(forPage: page), + let mediaViewerPage = makeMediaViewerPage(with: pageID) else { preconditionFailure("Page \(page) out of range.") } setViewControllers([mediaViewerPage], direction: .forward, animated: false) @@ -358,7 +359,10 @@ open class MediaViewerViewController: UIPageViewController { /// Move to show media on the specified page. /// - Parameter page: The destination page. open func move(toPage page: Int, animated: Bool) { - guard let mediaViewerPage = makeMediaViewerPage(forPage: page) else { return } + guard let pageID = mediaViewerVM.pageID(forPage: page) else { + preconditionFailure("Page \(page) out of range.") + } + guard let mediaViewerPage = makeMediaViewerPage(with: pageID) else { return } setViewControllers( [mediaViewerPage], direction: page < currentPage ? .reverse : .forward, @@ -473,7 +477,10 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { return nil } let previousPage = mediaViewerPageVC.page - 1 - if let previousPageVC = makeMediaViewerPage(forPage: previousPage) { + guard let previousPageID = mediaViewerVM.pageID(forPage: previousPage) else { + return nil + } + if let previousPageVC = makeMediaViewerPage(with: previousPageID) { return previousPageVC } return nil @@ -488,16 +495,20 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { return nil } let nextPage = mediaViewerPageVC.page + 1 - if let nextPageVC = makeMediaViewerPage(forPage: nextPage) { + guard let nextPageID = mediaViewerVM.pageID(forPage: nextPage) else { + return nil + } + if let nextPageVC = makeMediaViewerPage(with: nextPageID) { return nextPageVC } return nil } private func makeMediaViewerPage( - forPage page: Int + with pageID: MediaViewerPageID ) -> MediaViewerOnePageViewController? { - guard let mediaViewerDataSource, + guard let page = mediaViewerVM.page(with: pageID), + let mediaViewerDataSource, 0 <= page, page < mediaViewerDataSource.numberOfMedia(in: self) else { return nil } let media = mediaViewerDataSource.mediaViewer(self, mediaOnPage: page) From 1840111d2e8d4478549b94c478457c0f7a2d2b6e Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 22:11:25 +0900 Subject: [PATCH 012/141] change: MediaViewerOnePageVC to have pageID instead of the page number --- .../MediaViewerOnePageViewController.swift | 8 ++++---- .../MediaViewerViewController.swift | 18 +++++++++--------- Sources/MediaViewer/MediaViewerViewModel.swift | 12 ++++++++++++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift index 760f3889..69abe36d 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift @@ -19,7 +19,7 @@ protocol MediaViewerOnePageViewControllerDelegate: AnyObject { final class MediaViewerOnePageViewController: UIViewController { - let page: Int + let pageID: MediaViewerPageID weak var delegate: (any MediaViewerOnePageViewControllerDelegate)? @@ -35,14 +35,14 @@ final class MediaViewerOnePageViewController: UIViewController { // MARK: - Initializers - init(page: Int) { - self.page = page + init(pageID: MediaViewerPageID) { + self.pageID = pageID super.init(nibName: nil, bundle: nil) } @available(*, unavailable, message: "init(coder:) is not supported.") required init?(coder: NSCoder) { - self.page = 0 + self.pageID = .init() super.init(coder: coder) } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 2869b0a2..b6a74724 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -45,7 +45,11 @@ open class MediaViewerViewController: UIPageViewController { /// The current page of the media viewer. public var currentPage: Int { - currentPageViewController.page + mediaViewerVM.page(with: currentPageID)! + } + + var currentPageID: MediaViewerPageID { + currentPageViewController.pageID } var currentPageViewController: MediaViewerOnePageViewController { @@ -476,8 +480,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - let previousPage = mediaViewerPageVC.page - 1 - guard let previousPageID = mediaViewerVM.pageID(forPage: previousPage) else { + guard let previousPageID = mediaViewerVM.previousPageID(of: mediaViewerPageVC.pageID) else { return nil } if let previousPageVC = makeMediaViewerPage(with: previousPageID) { @@ -494,8 +497,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - let nextPage = mediaViewerPageVC.page + 1 - guard let nextPageID = mediaViewerVM.pageID(forPage: nextPage) else { + guard let nextPageID = mediaViewerVM.nextPageID(of: mediaViewerPageVC.pageID) else { return nil } if let nextPageVC = makeMediaViewerPage(with: nextPageID) { @@ -508,12 +510,10 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { with pageID: MediaViewerPageID ) -> MediaViewerOnePageViewController? { guard let page = mediaViewerVM.page(with: pageID), - let mediaViewerDataSource, - 0 <= page, - page < mediaViewerDataSource.numberOfMedia(in: self) else { return nil } + let mediaViewerDataSource else { return nil } let media = mediaViewerDataSource.mediaViewer(self, mediaOnPage: page) - let mediaViewerPage = MediaViewerOnePageViewController(page: page) + let mediaViewerPage = MediaViewerOnePageViewController(pageID: pageID) mediaViewerPage.delegate = self switch media { case .image(.sync(let image)): diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 4e95e626..708b2109 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -30,4 +30,16 @@ final class MediaViewerViewModel: ObservableObject { func page(with pageID: MediaViewerPageID) -> Int? { pageIDs.firstIndex(of: pageID) } + + func previousPageID(of id: MediaViewerPageID) -> MediaViewerPageID? { + guard let page = page(with: id) else { return nil } + let previousPage = page - 1 + return pageID(forPage: previousPage) + } + + func nextPageID(of id: MediaViewerPageID) -> MediaViewerPageID? { + guard let page = page(with: id) else { return nil } + let nextPage = page + 1 + return pageID(forPage: nextPage) + } } From 4741608d1e416c30dce908de833c30315a206aa6 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 11 Nov 2023 03:48:55 +0900 Subject: [PATCH 013/141] delete: MediaViewerPageControlBar.Item # Conflicts: # Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift --- .../MediaViewerPageControlBar.swift | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index a45cb717..90964fcf 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -24,8 +24,6 @@ protocol MediaViewerPageControlBarDataSource: AnyObject { final class MediaViewerPageControlBar: UIView { - typealias Item = MediaViewerPageID - enum State: Hashable, Sendable { case collapsing @@ -56,7 +54,7 @@ final class MediaViewerPageControlBar: UIView { private typealias CellRegistration = UICollectionView.CellRegistration< PageControlBarThumbnailCell, - Item + MediaViewerPageID > weak var dataSource: (any MediaViewerPageControlBarDataSource)? @@ -111,7 +109,7 @@ final class MediaViewerPageControlBar: UIView { return collectionView }() - lazy var diffableDataSource = UICollectionViewDiffableDataSource( + lazy var diffableDataSource = UICollectionViewDiffableDataSource( collectionView: collectionView ) { [weak self] collectionView, indexPath, item in guard let self else { return nil } @@ -122,7 +120,7 @@ final class MediaViewerPageControlBar: UIView { ) } - private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, item in + private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, pageID in guard let self, let dataSource else { return } let scale = window?.screen.scale ?? 3 let preferredSize = CGSize( @@ -131,7 +129,7 @@ final class MediaViewerPageControlBar: UIView { ) let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailOnPage: page(of: item)!, + thumbnailOnPage: page(with: pageID)!, filling: preferredSize ) cell.configure(with: thumbnailSource) @@ -208,16 +206,16 @@ final class MediaViewerPageControlBar: UIView { } } - private func page(of item: Item) -> Int? { - diffableDataSource.snapshot().indexOfItem(item) + private func page(with pageID: MediaViewerPageID) -> Int? { + diffableDataSource.snapshot().indexOfItem(pageID) } - private func item(forPage page: Int) -> Item { + private func pageID(forPage page: Int) -> MediaViewerPageID { diffableDataSource.snapshot().itemIdentifiers[page] } - private func cell(for item: Item) -> PageControlBarThumbnailCell? { - guard let indexPath = diffableDataSource.indexPath(for: item), + private func cell(for pageID: MediaViewerPageID) -> PageControlBarThumbnailCell? { + guard let indexPath = diffableDataSource.indexPath(for: pageID), let cell = collectionView.cellForItem(at: indexPath) else { return nil } From 884bbfdc0638faeb64ed4f5edd3606fb01e9e821 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 17 Nov 2023 21:29:49 +0900 Subject: [PATCH 014/141] chore: refactor --- Sources/MediaViewer/MediaViewerViewController.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b6a74724..38e595df 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -483,10 +483,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { guard let previousPageID = mediaViewerVM.previousPageID(of: mediaViewerPageVC.pageID) else { return nil } - if let previousPageVC = makeMediaViewerPage(with: previousPageID) { - return previousPageVC - } - return nil + return makeMediaViewerPage(with: previousPageID) } open func pageViewController( @@ -500,10 +497,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { guard let nextPageID = mediaViewerVM.nextPageID(of: mediaViewerPageVC.pageID) else { return nil } - if let nextPageVC = makeMediaViewerPage(with: nextPageID) { - return nextPageVC - } - return nil + return makeMediaViewerPage(with: nextPageID) } private func makeMediaViewerPage( From 8cb6200bcc97cbfc4e18022bb4d214ce6ceaf812 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 17 Nov 2023 21:32:31 +0900 Subject: [PATCH 015/141] rename: item => pageID --- .../PageControlBar/MediaViewerPageControlBar.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 90964fcf..e1eaf8ae 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -111,12 +111,12 @@ final class MediaViewerPageControlBar: UIView { lazy var diffableDataSource = UICollectionViewDiffableDataSource( collectionView: collectionView - ) { [weak self] collectionView, indexPath, item in + ) { [weak self] collectionView, indexPath, pageID in guard let self else { return nil } return collectionView.dequeueConfiguredReusableCell( using: cellRegistration, for: indexPath, - item: item + item: pageID ) } From c54908023c7c3d8b9834b5b17ff21b0fa6524dcb Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 17 Nov 2023 21:40:57 +0900 Subject: [PATCH 016/141] fix: missing scheme --- .../xcschemes/MediaViewerDemo.xcscheme | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme diff --git a/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme b/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme new file mode 100644 index 00000000..d3bb377f --- /dev/null +++ b/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 086a355884bb84192ac3a881e6967823ec370564 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 18 Nov 2023 20:01:11 +0900 Subject: [PATCH 017/141] change: type of MediaViewerPageID.rawValue to AnyHashable --- .../MediaViewerOnePage/MediaViewerOnePageViewController.swift | 3 +-- Sources/MediaViewer/MediaViewerViewController.swift | 3 ++- Sources/MediaViewer/MediaViewerViewModel.swift | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift index 69abe36d..4763c170 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift @@ -42,8 +42,7 @@ final class MediaViewerOnePageViewController: UIViewController { @available(*, unavailable, message: "init(coder:) is not supported.") required init?(coder: NSCoder) { - self.pageID = .init() - super.init(coder: coder) + fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 38e595df..69d07f00 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -8,9 +8,10 @@ import UIKit import Combine +// TODO: Rename to AnyMediaIdentifier /// An identifier of the media viewer page. struct MediaViewerPageID: Hashable, Sendable { - let rawValue = UUID() + let rawValue: AnyHashable } /// An media viewer. diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 708b2109..22163038 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -5,6 +5,7 @@ // Created by Yusaku Nishi on 2023/02/25. // +import Foundation import Combine final class MediaViewerViewModel: ObservableObject { @@ -19,7 +20,7 @@ final class MediaViewerViewModel: ObservableObject { // MARK: - Methods func setUpPageIDs(numberOfMedia: Int) { - pageIDs = (0.. MediaViewerPageID? { From d7b6bb4e5e86473493f8749503fbf7a7d5f86498 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 18 Nov 2023 20:12:04 +0900 Subject: [PATCH 018/141] change: remove conforming to Sendable --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 69d07f00..32e72667 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -10,7 +10,7 @@ import Combine // TODO: Rename to AnyMediaIdentifier /// An identifier of the media viewer page. -struct MediaViewerPageID: Hashable, Sendable { +struct MediaViewerPageID: Hashable { let rawValue: AnyHashable } From 7d4b257578533a05f95b15cc435959768c56337b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 18 Nov 2023 23:17:00 +0900 Subject: [PATCH 019/141] add: new MediaViewerDataSource that handles media identifiers It will be renamed later. --- .../MediaViewer/MediaViewerDataSource.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index fbac3aaa..97e89c8b 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -7,6 +7,136 @@ import UIKit +/// The object you use to provide data for an media viewer. +@MainActor +public protocol MediaViewerDataSource_: AnyObject { + + /// A type representing the unique identifier for media. + associatedtype MediaIdentifier: Hashable + + /// Asks the data source to return all identifiers for media to view in the media viewer. + /// - Parameter mediaViewer: An object representing the media viewer requesting this information. + /// - Returns: All identifiers for media to view in the `mediaViewer`. + func mediaIdentifiers( + for mediaViewer: MediaViewerViewController + ) -> [MediaIdentifier] + + /// Asks the data source to return media with the specified identifier to view in the media viewer. + /// - Parameters: + /// - mediaViewer: An object representing the media viewer requesting this information. + /// - mediaIdentifier: An identifier for media. + /// - Returns: Media with `mediaIdentifier` to view in `mediaViewer`. + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + mediaWith mediaIdentifier: MediaIdentifier + ) -> Media + + /// Asks the data source to return an aspect ratio of media with the specified identifier. + /// + /// The ratio will be used to determine a size of page thumbnail. + /// This method should return immediately. + /// + /// - Parameters: + /// - mediaViewer: An object representing the media viewer requesting this information. + /// - mediaIdentifier: An identifier for media. + /// - Returns: An aspect ratio of media with `mediaIdentifier`. + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + widthToHeightOfMediaWith mediaIdentifier: MediaIdentifier + ) -> CGFloat? + + /// Asks the data source to return a source of a thumbnail image on the page control bar in the media viewer. + /// - Parameters: + /// - mediaViewer: An object representing the media viewer requesting this information. + /// - mediaIdentifier: An identifier for media. + /// - preferredThumbnailSize: An expected size of the thumbnail image. For better performance, it is preferable to shrink the thumbnail image to a size that fills this size. + /// - Returns: A source of a thumbnail image on the page control bar in `mediaViewer`. + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + pageThumbnailForMediaWith mediaIdentifier: MediaIdentifier, + filling preferredThumbnailSize: CGSize + ) -> Source + + /// Asks the data source to return the transition source view for media currently viewed in the viewer. + /// + /// The media viewer uses this view for push or pop transitions. + /// On the push transition, an animation runs as the image expands from this view. The reverse happens on the pop. + /// + /// If `nil`, the animation looks like cross-dissolve. + /// + /// - Parameter mediaViewer: An object representing the media viewer requesting this information. + /// - Returns: The transition source view for current media of `mediaViewer`. + func transitionSourceView( + forCurrentMediaOf mediaViewer: MediaViewerViewController + ) -> UIView? + + /// Asks the data source to return the transition source image for current media of the viewer. + /// + /// The media viewer uses this image for the push transition if needed. + /// If the viewer has not yet acquired an image asynchronously at the start of the push transition, + /// the viewer starts a transition animation with this image. + /// + /// - Parameters: + /// - mediaViewer: An object representing the media viewer requesting this information. + /// - sourceView: A transition source view that is returned from `transitionSourceView(forCurrentMediaOf:)` method. + /// - Returns: The transition source image for current media of `mediaViewer`. + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceImageWith sourceView: UIView? + ) -> UIImage? +} + +// MARK: - Default implementations - + +extension MediaViewerDataSource_ { + + public func mediaViewer( + _ mediaViewer: MediaViewerViewController, + widthToHeightOfMediaWith mediaIdentifier: MediaIdentifier + ) -> CGFloat? { + let media = self.mediaViewer(mediaViewer, mediaWith: mediaIdentifier) + switch media { + case .image(.sync(let image?)) where image.size.height > 0: + return image.size.width / image.size.height + case .image(.sync), .image(.async): + return nil + } + } + + public func mediaViewer( + _ mediaViewer: MediaViewerViewController, + pageThumbnailForMediaWith mediaIdentifier: MediaIdentifier, + filling preferredThumbnailSize: CGSize + ) -> Source { + let media = self.mediaViewer(mediaViewer, mediaWith: mediaIdentifier) + switch media { + case .image(.sync(let image)): + return .sync( + image?.preparingThumbnail(of: preferredThumbnailSize) ?? image + ) + case .image(.async(let transition, let imageProvider)): + return .async(transition: transition) { + let image = await imageProvider() + return await image?.byPreparingThumbnail( + ofSize: preferredThumbnailSize + ) ?? image + } + } + } + + public func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceImageWith sourceView: UIView? + ) -> UIImage? { + switch sourceView { + case let sourceImageView as UIImageView: + return sourceImageView.image + default: + return nil + } + } +} + /// The object you use to provide data for an media viewer. @MainActor public protocol MediaViewerDataSource: AnyObject { From 4282093b31dbac6c20dd47a97b602b3094c19443 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 18 Nov 2023 23:25:58 +0900 Subject: [PATCH 020/141] add: new MediaViewerPageControlBarDataSource that handles page identifiers It will be renamed later. --- .../PageControlBar/MediaViewerPageControlBar.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index e1eaf8ae..9a6aaf14 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -8,6 +8,20 @@ import UIKit import Combine +@MainActor +protocol MediaViewerPageControlBarDataSource_: AnyObject { + func mediaViewerPageControlBar( + _ pageControlBar: MediaViewerPageControlBar, + thumbnailWith pageID: MediaViewerPageID, + filling preferredThumbnailSize: CGSize + ) -> Source + + func mediaViewerPageControlBar( + _ pageControlBar: MediaViewerPageControlBar, + widthToHeightOfThumbnailWith pageID: MediaViewerPageID + ) -> CGFloat? +} + @MainActor protocol MediaViewerPageControlBarDataSource: AnyObject { func mediaViewerPageControlBar( From 95e6c8eec947cde06c04c47bfd5eb3e367a5d427 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 18 Nov 2023 23:31:40 +0900 Subject: [PATCH 021/141] change: replace MediaViewerPageControlBarDataSource with new version --- .../MediaViewer/MediaViewerViewController.swift | 10 ++++++---- .../PageControlBar/MediaViewerPageControlBar.swift | 14 ++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 32e72667..b0a6565a 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -529,10 +529,11 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - thumbnailOnPage page: Int, + thumbnailWith pageID: MediaViewerPageID, filling preferredThumbnailSize: CGSize ) -> Source { - guard let mediaViewerDataSource else { return .none } + guard let mediaViewerDataSource, + let page = mediaViewerVM.page(with: pageID) else { return .none } return mediaViewerDataSource.mediaViewer( self, pageThumbnailOnPage: page, @@ -542,9 +543,10 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - thumbnailWidthToHeightOnPage page: Int + widthToHeightOfThumbnailWith pageID: MediaViewerPageID ) -> CGFloat? { - mediaViewerDataSource?.mediaViewer(self, mediaWidthToHeightOnPage: page) + guard let page = mediaViewerVM.page(with: pageID) else { return nil } + return mediaViewerDataSource?.mediaViewer(self, mediaWidthToHeightOnPage: page) } } diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 9a6aaf14..0eade2a8 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -9,7 +9,7 @@ import UIKit import Combine @MainActor -protocol MediaViewerPageControlBarDataSource_: AnyObject { +protocol MediaViewerPageControlBarDataSource: AnyObject { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, thumbnailWith pageID: MediaViewerPageID, @@ -23,7 +23,7 @@ protocol MediaViewerPageControlBarDataSource_: AnyObject { } @MainActor -protocol MediaViewerPageControlBarDataSource: AnyObject { +protocol DeprecatedMediaViewerPageControlBarDataSource: AnyObject { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, thumbnailOnPage page: Int, @@ -143,7 +143,7 @@ final class MediaViewerPageControlBar: UIView { ) let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailOnPage: page(with: pageID)!, + thumbnailWith: pageID, filling: preferredSize ) cell.configure(with: thumbnailSource) @@ -307,8 +307,9 @@ final class MediaViewerPageControlBar: UIView { private func correctExpandingItemAspectRatioIfNeeded() { guard let indexPathForCurrentCenterItem, let dataSource else { return } let page = indexPathForCurrentCenterItem.item + let pageID = pageID(forPage: page) - if let thumbnailWidthToHeight = dataSource.mediaViewerPageControlBar(self, thumbnailWidthToHeightOnPage: page) { + if let thumbnailWidthToHeight = dataSource.mediaViewerPageControlBar(self, widthToHeightOfThumbnailWith: pageID) { expandAndScrollToItem( at: indexPathForCurrentCenterItem, causingBy: nil, @@ -320,7 +321,7 @@ final class MediaViewerPageControlBar: UIView { let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailOnPage: page, + thumbnailWith: pageID, filling: .init(width: 100, height: 100) ) switch thumbnailSource { @@ -389,9 +390,10 @@ extension MediaViewerPageControlBar { return } + let destinationPageID = pageID(forPage: destinationPage) let expandingThumbnailWidthToHeight = dataSource?.mediaViewerPageControlBar( self, - thumbnailWidthToHeightOnPage: destinationPage + widthToHeightOfThumbnailWith: destinationPageID ) let style: MediaViewerPageControlBarLayout.Style = .expanded( IndexPath(item: destinationPage, section: 0), From 51891edecc7cd5c1ad774a2a7e371737f018c391 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 00:11:07 +0900 Subject: [PATCH 022/141] change: replace MediaViewerDataSource with new version --- .../Camera/CameraLikeViewController.swift | 16 +++---- .../Grid/AsyncImagesViewController.swift | 25 +++++------ .../Grid/SyncImagesViewController.swift | 10 ++--- .../MediaViewer/MediaViewerDataSource.swift | 45 +++++++++++++++++-- .../MediaViewerViewController.swift | 26 +++++------ 5 files changed, 78 insertions(+), 44 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift index a3268047..b406c7da 100644 --- a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift @@ -115,30 +115,28 @@ final class CameraLikeViewController: UIViewController { extension CameraLikeViewController: MediaViewerDataSource { - func numberOfMedia(in mediaViewer: MediaViewerViewController) -> Int { - assets.count + func mediaIdentifiers(for mediaViewer: MediaViewerViewController) -> [PHAsset] { + assets } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaOnPage page: Int + mediaWith mediaIdentifier: PHAsset ) -> Media { - let asset = assets[page] - return .async { await PHImageFetcher.image(for: asset) } + .async { await PHImageFetcher.image(for: mediaIdentifier) } } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaWidthToHeightOnPage page: Int + widthToHeightOfMediaWith mediaIdentifier: PHAsset ) -> CGFloat? { - let asset = assets[page] - let size = PHImageFetcher.imageSize(of: asset) + let size = PHImageFetcher.imageSize(of: mediaIdentifier) guard let size, size.height > 0 else { return nil } return size.width / size.height } func transitionSourceView( - forCurrentPageOf mediaViewer: MediaViewerViewController + forCurrentMediaOf mediaViewer: MediaViewerViewController ) -> UIView? { cameraLikeView.showLibraryButton } diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 97b209ee..fe9e500d 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -161,37 +161,34 @@ extension AsyncImagesViewController: UICollectionViewDelegate { extension AsyncImagesViewController: MediaViewerDataSource { - func numberOfMedia(in mediaViewer: MediaViewerViewController) -> Int { - dataSource.snapshot().numberOfItems + func mediaIdentifiers(for mediaViewer: MediaViewerViewController) -> [PHAsset] { + dataSource.snapshot().itemIdentifiers } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaOnPage page: Int + mediaWith mediaIdentifier: PHAsset ) -> Media { - let asset = dataSource.snapshot().itemIdentifiers[page] - return .async { await PHImageFetcher.image(for: asset) } + .async { await PHImageFetcher.image(for: mediaIdentifier) } } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaWidthToHeightOnPage page: Int + widthToHeightOfMediaWith mediaIdentifier: PHAsset ) -> CGFloat? { - let asset = dataSource.snapshot().itemIdentifiers[page] - let size = PHImageFetcher.imageSize(of: asset) + let size = PHImageFetcher.imageSize(of: mediaIdentifier) guard let size, size.height > 0 else { return nil } return size.width / size.height } func mediaViewer( _ mediaViewer: MediaViewerViewController, - pageThumbnailOnPage page: Int, + pageThumbnailForMediaWith mediaIdentifier: PHAsset, filling preferredThumbnailSize: CGSize ) -> Source { - let asset = dataSource.snapshot().itemIdentifiers[page] - return .async(transition: .fade(duration: 0.1)) { + .async(transition: .fade(duration: 0.1)) { await PHImageFetcher.image( - for: asset, + for: mediaIdentifier, targetSize: preferredThumbnailSize, contentMode: .aspectFill, resizeMode: .fast @@ -199,7 +196,9 @@ extension AsyncImagesViewController: MediaViewerDataSource { } } - func transitionSourceView(forCurrentPageOf mediaViewer: MediaViewerViewController) -> UIView? { + func transitionSourceView( + forCurrentMediaOf mediaViewer: MediaViewerViewController + ) -> UIView? { let currentPage = mediaViewer.currentPage let indexPathForCurrentImage = IndexPath(item: currentPage, section: 0) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 3bfc97ae..c72e0c71 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -75,19 +75,19 @@ extension SyncImagesViewController: UICollectionViewDelegate { extension SyncImagesViewController: MediaViewerDataSource { - func numberOfMedia(in mediaViewer: MediaViewerViewController) -> Int { - dataSource.snapshot().numberOfItems + func mediaIdentifiers(for mediaViewer: MediaViewerViewController) -> [UIImage] { + dataSource.snapshot().itemIdentifiers } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaOnPage page: Int + mediaWith mediaIdentifier: UIImage ) -> Media { - .sync(dataSource.snapshot().itemIdentifiers[page]) + .sync(mediaIdentifier) } func transitionSourceView( - forCurrentPageOf mediaViewer: MediaViewerViewController + forCurrentMediaOf mediaViewer: MediaViewerViewController ) -> UIView? { let currentPage = mediaViewer.currentPage let indexPathForCurrentImage = IndexPath(item: currentPage, section: 0) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index 97e89c8b..d44d055d 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -9,7 +9,7 @@ import UIKit /// The object you use to provide data for an media viewer. @MainActor -public protocol MediaViewerDataSource_: AnyObject { +public protocol MediaViewerDataSource: AnyObject { /// A type representing the unique identifier for media. associatedtype MediaIdentifier: Hashable @@ -88,7 +88,7 @@ public protocol MediaViewerDataSource_: AnyObject { // MARK: - Default implementations - -extension MediaViewerDataSource_ { +extension MediaViewerDataSource { public func mediaViewer( _ mediaViewer: MediaViewerViewController, @@ -137,9 +137,46 @@ extension MediaViewerDataSource_ { } } +// MARK: - Open existential types - + +extension MediaViewerDataSource { + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + mediaWith pageID: MediaViewerPageID + ) -> Media { + self.mediaViewer( + mediaViewer, + mediaWith: pageID.rawValue as! MediaIdentifier + ) + } + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + widthToHeightOfMediaWith pageID: MediaViewerPageID + ) -> CGFloat? { + self.mediaViewer( + mediaViewer, + widthToHeightOfMediaWith: pageID.rawValue as! MediaIdentifier + ) + } + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + pageThumbnailForMediaWith pageID: MediaViewerPageID, + filling preferredThumbnailSize: CGSize + ) -> Source { + self.mediaViewer( + mediaViewer, + pageThumbnailForMediaWith: pageID.rawValue as! MediaIdentifier, + filling: preferredThumbnailSize + ) + } +} + /// The object you use to provide data for an media viewer. @MainActor -public protocol MediaViewerDataSource: AnyObject { +public protocol DeprecatedMediaViewerDataSource: AnyObject { /// Asks the data source to return the number of media in the media viewer. /// - Parameter mediaViewer: An object representing the media viewer requesting this information. @@ -213,7 +250,7 @@ public protocol MediaViewerDataSource: AnyObject { // MARK: - Default implementations - -extension MediaViewerDataSource { +extension DeprecatedMediaViewerDataSource { public func mediaViewer( _ mediaViewer: MediaViewerViewController, diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b0a6565a..41b60d65 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -134,8 +134,8 @@ open class MediaViewerViewController: UIPageViewController { ) mediaViewerDataSource = dataSource - let numberOfMedia = dataSource.numberOfMedia(in: self) - mediaViewerVM.setUpPageIDs(numberOfMedia: numberOfMedia) + let mediaIdentifiers = dataSource.mediaIdentifiers(for: self) + mediaViewerVM.pageIDs = mediaIdentifiers.map(MediaViewerPageID.init) guard let pageID = mediaViewerVM.pageID(forPage: page), let mediaViewerPage = makeMediaViewerPage(with: pageID) else { @@ -410,7 +410,7 @@ open class MediaViewerViewController: UIPageViewController { if recognizer.state == .began { // Start the interactive pop transition let sourceView = mediaViewerDataSource?.transitionSourceView( - forCurrentPageOf: self + forCurrentMediaOf: self ) interactivePopTransition = .init(sourceView: sourceView) @@ -470,7 +470,7 @@ extension MediaViewerViewController: MediaViewerOnePageViewControllerDelegate { extension MediaViewerViewController: UIPageViewControllerDataSource { open func presentationCount(for pageViewController: UIPageViewController) -> Int { - mediaViewerDataSource?.numberOfMedia(in: self) ?? 0 + mediaViewerVM.pageIDs.count } open func pageViewController( @@ -504,9 +504,8 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { private func makeMediaViewerPage( with pageID: MediaViewerPageID ) -> MediaViewerOnePageViewController? { - guard let page = mediaViewerVM.page(with: pageID), - let mediaViewerDataSource else { return nil } - let media = mediaViewerDataSource.mediaViewer(self, mediaOnPage: page) + guard let mediaViewerDataSource else { return nil } + let media = mediaViewerDataSource.mediaViewer(self, mediaWith: pageID) let mediaViewerPage = MediaViewerOnePageViewController(pageID: pageID) mediaViewerPage.delegate = self @@ -532,11 +531,10 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { thumbnailWith pageID: MediaViewerPageID, filling preferredThumbnailSize: CGSize ) -> Source { - guard let mediaViewerDataSource, - let page = mediaViewerVM.page(with: pageID) else { return .none } + guard let mediaViewerDataSource else { return .none } return mediaViewerDataSource.mediaViewer( self, - pageThumbnailOnPage: page, + pageThumbnailForMediaWith: pageID, filling: preferredThumbnailSize ) } @@ -545,8 +543,10 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { _ pageControlBar: MediaViewerPageControlBar, widthToHeightOfThumbnailWith pageID: MediaViewerPageID ) -> CGFloat? { - guard let page = mediaViewerVM.page(with: pageID) else { return nil } - return mediaViewerDataSource?.mediaViewer(self, mediaWidthToHeightOnPage: page) + mediaViewerDataSource?.mediaViewer( + self, + widthToHeightOfMediaWith: pageID + ) } } @@ -583,7 +583,7 @@ extension MediaViewerViewController: UINavigationControllerDelegate { ) } let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource?.transitionSourceView( - forCurrentPageOf: self + forCurrentMediaOf: self ) return MediaViewerTransition( operation: operation, From ed9ed0e4cee37c9b6af1d0ed7cbd88342dba87c7 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 00:12:21 +0900 Subject: [PATCH 023/141] delete: old data sources --- .../MediaViewer/MediaViewerDataSource.swift | 125 ------------------ .../MediaViewerPageControlBar.swift | 14 -- 2 files changed, 139 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index d44d055d..dd5a1f50 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -173,128 +173,3 @@ extension MediaViewerDataSource { ) } } - -/// The object you use to provide data for an media viewer. -@MainActor -public protocol DeprecatedMediaViewerDataSource: AnyObject { - - /// Asks the data source to return the number of media in the media viewer. - /// - Parameter mediaViewer: An object representing the media viewer requesting this information. - /// - Returns: The number of media in `mediaViewer`. - func numberOfMedia(in mediaViewer: MediaViewerViewController) -> Int - - /// Asks the data source to return media to view at the particular page in the media viewer. - /// - Parameters: - /// - mediaViewer: An object representing the media viewer requesting this information. - /// - page: A page in the media viewer. - /// - Returns: Media to view on `page` in `mediaViewer`. - func mediaViewer( - _ mediaViewer: MediaViewerViewController, - mediaOnPage page: Int - ) -> Media - - /// Asks the data source to return an aspect ratio of media. - /// - /// The ratio will be used to determine a size of page thumbnail. - /// This method should return immediately. - /// - /// - Parameters: - /// - mediaViewer: An object representing the media viewer requesting this information. - /// - page: A page in the media viewer. - /// - Returns: An aspect ratio of media on the specified page. - func mediaViewer( - _ mediaViewer: MediaViewerViewController, - mediaWidthToHeightOnPage page: Int - ) -> CGFloat? - - /// Asks the data source to return a source of a thumbnail image on the page control bar in the media viewer. - /// - Parameters: - /// - mediaViewer: An object representing the media viewer requesting this information. - /// - page: A page in the media viewer. - /// - preferredThumbnailSize: An expected size of the thumbnail image. For better performance, it is preferable to shrink the thumbnail image to a size that fills this size. - /// - Returns: A source of a thumbnail image on the page control bar in `mediaViewer`. - func mediaViewer( - _ mediaViewer: MediaViewerViewController, - pageThumbnailOnPage page: Int, - filling preferredThumbnailSize: CGSize - ) -> Source - - /// Asks the data source to return the transition source view for the current page of the media viewer. - /// - /// The media viewer uses this view for push or pop transitions. - /// On the push transition, an animation runs as the image expands from this view. The reverse happens on the pop. - /// - /// If `nil`, the animation looks like cross-dissolve. - /// - /// - Parameter mediaViewer: An object representing the media viewer requesting this information. - /// - Returns: The transition source view for current page of `mediaViewer`. - func transitionSourceView( - forCurrentPageOf mediaViewer: MediaViewerViewController - ) -> UIView? - - /// Asks the data source to return the transition source image for the current page of the media viewer. - /// - /// The media viewer uses this image for the push transition if needed. - /// If the viewer has not yet acquired an image asynchronously at the start of the push transition, - /// the viewer starts a transition animation with this image. - /// - /// - Parameters: - /// - mediaViewer: An object representing the media viewer requesting this information. - /// - sourceView: A transition source view that is returned from `transitionSourceView(forCurrentPageOf:)` method. - /// - Returns: The transition source image for current page of `mediaViewer`. - func mediaViewer( - _ mediaViewer: MediaViewerViewController, - transitionSourceImageWith sourceView: UIView? - ) -> UIImage? -} - -// MARK: - Default implementations - - -extension DeprecatedMediaViewerDataSource { - - public func mediaViewer( - _ mediaViewer: MediaViewerViewController, - mediaWidthToHeightOnPage page: Int - ) -> CGFloat? { - let media = self.mediaViewer(mediaViewer, mediaOnPage: page) - switch media { - case .image(.sync(let image?)) where image.size.height > 0: - return image.size.width / image.size.height - case .image(.sync), .image(.async): - return nil - } - } - - public func mediaViewer( - _ mediaViewer: MediaViewerViewController, - pageThumbnailOnPage page: Int, - filling preferredThumbnailSize: CGSize - ) -> Source { - let media = self.mediaViewer(mediaViewer, mediaOnPage: page) - switch media { - case .image(.sync(let image)): - return .sync( - image?.preparingThumbnail(of: preferredThumbnailSize) ?? image - ) - case .image(.async(let transition, let imageProvider)): - return .async(transition: transition) { - let image = await imageProvider() - return await image?.byPreparingThumbnail( - ofSize: preferredThumbnailSize - ) ?? image - } - } - } - - public func mediaViewer( - _ mediaViewer: MediaViewerViewController, - transitionSourceImageWith sourceView: UIView? - ) -> UIImage? { - switch sourceView { - case let sourceImageView as UIImageView: - return sourceImageView.image - default: - return nil - } - } -} diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 0eade2a8..0863dded 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -22,20 +22,6 @@ protocol MediaViewerPageControlBarDataSource: AnyObject { ) -> CGFloat? } -@MainActor -protocol DeprecatedMediaViewerPageControlBarDataSource: AnyObject { - func mediaViewerPageControlBar( - _ pageControlBar: MediaViewerPageControlBar, - thumbnailOnPage page: Int, - filling preferredThumbnailSize: CGSize - ) -> Source - - func mediaViewerPageControlBar( - _ pageControlBar: MediaViewerPageControlBar, - thumbnailWidthToHeightOnPage page: Int - ) -> CGFloat? -} - final class MediaViewerPageControlBar: UIView { enum State: Hashable, Sendable { From 3bdd86463d552e619e76f12a0c8ebb512e64c657 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 00:12:32 +0900 Subject: [PATCH 024/141] delete: unused method --- Sources/MediaViewer/MediaViewerViewModel.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 22163038..0cd405a4 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -5,7 +5,6 @@ // Created by Yusaku Nishi on 2023/02/25. // -import Foundation import Combine final class MediaViewerViewModel: ObservableObject { @@ -19,10 +18,6 @@ final class MediaViewerViewModel: ObservableObject { // MARK: - Methods - func setUpPageIDs(numberOfMedia: Int) { - pageIDs = (0.. MediaViewerPageID? { guard 0 <= page && page < pageIDs.endIndex else { return nil } return pageIDs[page] From ccb4ece0bcf9f87c1922174b89206f5a5924df08 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 00:14:35 +0900 Subject: [PATCH 025/141] rename: MediaViewerPageID => AnyMediaIdentifier --- .../MediaViewer/MediaViewerDataSource.swift | 12 ++--- .../MediaViewerOnePageViewController.swift | 6 +-- .../MediaViewerViewController.swift | 51 ++++++++++--------- .../MediaViewer/MediaViewerViewModel.swift | 28 +++++----- .../MediaViewerPageControlBar.swift | 47 +++++++++-------- 5 files changed, 77 insertions(+), 67 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index dd5a1f50..fdc5d241 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -143,32 +143,32 @@ extension MediaViewerDataSource { func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaWith pageID: MediaViewerPageID + mediaWith mediaIdentifier: AnyMediaIdentifier ) -> Media { self.mediaViewer( mediaViewer, - mediaWith: pageID.rawValue as! MediaIdentifier + mediaWith: mediaIdentifier.rawValue as! MediaIdentifier ) } func mediaViewer( _ mediaViewer: MediaViewerViewController, - widthToHeightOfMediaWith pageID: MediaViewerPageID + widthToHeightOfMediaWith mediaIdentifier: AnyMediaIdentifier ) -> CGFloat? { self.mediaViewer( mediaViewer, - widthToHeightOfMediaWith: pageID.rawValue as! MediaIdentifier + widthToHeightOfMediaWith: mediaIdentifier.rawValue as! MediaIdentifier ) } func mediaViewer( _ mediaViewer: MediaViewerViewController, - pageThumbnailForMediaWith pageID: MediaViewerPageID, + pageThumbnailForMediaWith mediaIdentifier: AnyMediaIdentifier, filling preferredThumbnailSize: CGSize ) -> Source { self.mediaViewer( mediaViewer, - pageThumbnailForMediaWith: pageID.rawValue as! MediaIdentifier, + pageThumbnailForMediaWith: mediaIdentifier.rawValue as! MediaIdentifier, filling: preferredThumbnailSize ) } diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift index 4763c170..f34cc1a6 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageViewController.swift @@ -19,7 +19,7 @@ protocol MediaViewerOnePageViewControllerDelegate: AnyObject { final class MediaViewerOnePageViewController: UIViewController { - let pageID: MediaViewerPageID + let mediaIdentifier: AnyMediaIdentifier weak var delegate: (any MediaViewerOnePageViewControllerDelegate)? @@ -35,8 +35,8 @@ final class MediaViewerOnePageViewController: UIViewController { // MARK: - Initializers - init(pageID: MediaViewerPageID) { - self.pageID = pageID + init(mediaIdentifier: AnyMediaIdentifier) { + self.mediaIdentifier = mediaIdentifier super.init(nibName: nil, bundle: nil) } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 41b60d65..3ab49031 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -8,9 +8,8 @@ import UIKit import Combine -// TODO: Rename to AnyMediaIdentifier -/// An identifier of the media viewer page. -struct MediaViewerPageID: Hashable { +/// A type-erased media identifier. +struct AnyMediaIdentifier: Hashable { let rawValue: AnyHashable } @@ -46,11 +45,11 @@ open class MediaViewerViewController: UIPageViewController { /// The current page of the media viewer. public var currentPage: Int { - mediaViewerVM.page(with: currentPageID)! + mediaViewerVM.page(with: currentMediaIdentifier)! } - var currentPageID: MediaViewerPageID { - currentPageViewController.pageID + var currentMediaIdentifier: AnyMediaIdentifier { + currentPageViewController.mediaIdentifier } var currentPageViewController: MediaViewerOnePageViewController { @@ -134,11 +133,11 @@ open class MediaViewerViewController: UIPageViewController { ) mediaViewerDataSource = dataSource - let mediaIdentifiers = dataSource.mediaIdentifiers(for: self) - mediaViewerVM.pageIDs = mediaIdentifiers.map(MediaViewerPageID.init) + let identifiers = dataSource.mediaIdentifiers(for: self) + mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) - guard let pageID = mediaViewerVM.pageID(forPage: page), - let mediaViewerPage = makeMediaViewerPage(with: pageID) else { + guard let identifier = mediaViewerVM.mediaIdentifier(forPage: page), + let mediaViewerPage = makeMediaViewerPage(with: identifier) else { preconditionFailure("Page \(page) out of range.") } setViewControllers([mediaViewerPage], direction: .forward, animated: false) @@ -194,7 +193,7 @@ open class MediaViewerViewController: UIPageViewController { view.addSubview(pageControlToolbar) pageControlBar.configure( - pageIDs: mediaViewerVM.pageIDs, + mediaIdentifiers: mediaViewerVM.mediaIdentifiers, currentPage: currentPage ) pageControlToolbar.addSubview(pageControlBar) @@ -364,10 +363,10 @@ open class MediaViewerViewController: UIPageViewController { /// Move to show media on the specified page. /// - Parameter page: The destination page. open func move(toPage page: Int, animated: Bool) { - guard let pageID = mediaViewerVM.pageID(forPage: page) else { + guard let identifier = mediaViewerVM.mediaIdentifier(forPage: page) else { preconditionFailure("Page \(page) out of range.") } - guard let mediaViewerPage = makeMediaViewerPage(with: pageID) else { return } + guard let mediaViewerPage = makeMediaViewerPage(with: identifier) else { return } setViewControllers( [mediaViewerPage], direction: page < currentPage ? .reverse : .forward, @@ -470,7 +469,7 @@ extension MediaViewerViewController: MediaViewerOnePageViewControllerDelegate { extension MediaViewerViewController: UIPageViewControllerDataSource { open func presentationCount(for pageViewController: UIPageViewController) -> Int { - mediaViewerVM.pageIDs.count + mediaViewerVM.mediaIdentifiers.count } open func pageViewController( @@ -481,10 +480,10 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - guard let previousPageID = mediaViewerVM.previousPageID(of: mediaViewerPageVC.pageID) else { + guard let previousIdentifier = mediaViewerVM.previousMediaIdentifier(of: mediaViewerPageVC.mediaIdentifier) else { return nil } - return makeMediaViewerPage(with: previousPageID) + return makeMediaViewerPage(with: previousIdentifier) } open func pageViewController( @@ -495,19 +494,21 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - guard let nextPageID = mediaViewerVM.nextPageID(of: mediaViewerPageVC.pageID) else { + guard let nextIdentifier = mediaViewerVM.nextMediaIdentifier(of: mediaViewerPageVC.mediaIdentifier) else { return nil } - return makeMediaViewerPage(with: nextPageID) + return makeMediaViewerPage(with: nextIdentifier) } private func makeMediaViewerPage( - with pageID: MediaViewerPageID + with identifier: AnyMediaIdentifier ) -> MediaViewerOnePageViewController? { guard let mediaViewerDataSource else { return nil } - let media = mediaViewerDataSource.mediaViewer(self, mediaWith: pageID) + let media = mediaViewerDataSource.mediaViewer(self, mediaWith: identifier) - let mediaViewerPage = MediaViewerOnePageViewController(pageID: pageID) + let mediaViewerPage = MediaViewerOnePageViewController( + mediaIdentifier: identifier + ) mediaViewerPage.delegate = self switch media { case .image(.sync(let image)): @@ -528,24 +529,24 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - thumbnailWith pageID: MediaViewerPageID, + thumbnailWith mediaIdentifier: AnyMediaIdentifier, filling preferredThumbnailSize: CGSize ) -> Source { guard let mediaViewerDataSource else { return .none } return mediaViewerDataSource.mediaViewer( self, - pageThumbnailForMediaWith: pageID, + pageThumbnailForMediaWith: mediaIdentifier, filling: preferredThumbnailSize ) } func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - widthToHeightOfThumbnailWith pageID: MediaViewerPageID + widthToHeightOfThumbnailWith mediaIdentifier: AnyMediaIdentifier ) -> CGFloat? { mediaViewerDataSource?.mediaViewer( self, - widthToHeightOfMediaWith: pageID + widthToHeightOfMediaWith: mediaIdentifier ) } } diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 0cd405a4..b5fb81bf 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -12,30 +12,34 @@ final class MediaViewerViewModel: ObservableObject { /// Page identifiers of the media viewer. /// /// The page number corresponds to the index of this array. - @Published var pageIDs: [MediaViewerPageID] = [] + @Published var mediaIdentifiers: [AnyMediaIdentifier] = [] @Published var showsMediaOnly = false // MARK: - Methods - func pageID(forPage page: Int) -> MediaViewerPageID? { - guard 0 <= page && page < pageIDs.endIndex else { return nil } - return pageIDs[page] + func mediaIdentifier(forPage page: Int) -> AnyMediaIdentifier? { + guard 0 <= page && page < mediaIdentifiers.endIndex else { return nil } + return mediaIdentifiers[page] } - func page(with pageID: MediaViewerPageID) -> Int? { - pageIDs.firstIndex(of: pageID) + func page(with identifier: AnyMediaIdentifier) -> Int? { + mediaIdentifiers.firstIndex(of: identifier) } - func previousPageID(of id: MediaViewerPageID) -> MediaViewerPageID? { - guard let page = page(with: id) else { return nil } + func previousMediaIdentifier( + of identifier: AnyMediaIdentifier + ) -> AnyMediaIdentifier? { + guard let page = page(with: identifier) else { return nil } let previousPage = page - 1 - return pageID(forPage: previousPage) + return mediaIdentifier(forPage: previousPage) } - func nextPageID(of id: MediaViewerPageID) -> MediaViewerPageID? { - guard let page = page(with: id) else { return nil } + func nextMediaIdentifier( + of identifier: AnyMediaIdentifier + ) -> AnyMediaIdentifier? { + guard let page = page(with: identifier) else { return nil } let nextPage = page + 1 - return pageID(forPage: nextPage) + return mediaIdentifier(forPage: nextPage) } } diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 0863dded..2f38ce16 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -12,13 +12,13 @@ import Combine protocol MediaViewerPageControlBarDataSource: AnyObject { func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - thumbnailWith pageID: MediaViewerPageID, + thumbnailWith mediaIdentifier: AnyMediaIdentifier, filling preferredThumbnailSize: CGSize ) -> Source func mediaViewerPageControlBar( _ pageControlBar: MediaViewerPageControlBar, - widthToHeightOfThumbnailWith pageID: MediaViewerPageID + widthToHeightOfThumbnailWith mediaIdentifier: AnyMediaIdentifier ) -> CGFloat? } @@ -54,7 +54,7 @@ final class MediaViewerPageControlBar: UIView { private typealias CellRegistration = UICollectionView.CellRegistration< PageControlBarThumbnailCell, - MediaViewerPageID + AnyMediaIdentifier > weak var dataSource: (any MediaViewerPageControlBarDataSource)? @@ -109,18 +109,18 @@ final class MediaViewerPageControlBar: UIView { return collectionView }() - lazy var diffableDataSource = UICollectionViewDiffableDataSource( + lazy var diffableDataSource = UICollectionViewDiffableDataSource( collectionView: collectionView - ) { [weak self] collectionView, indexPath, pageID in + ) { [weak self] collectionView, indexPath, mediaIdentifier in guard let self else { return nil } return collectionView.dequeueConfiguredReusableCell( using: cellRegistration, for: indexPath, - item: pageID + item: mediaIdentifier ) } - private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, pageID in + private lazy var cellRegistration = CellRegistration { [weak self] cell, indexPath, mediaIdentifier in guard let self, let dataSource else { return } let scale = window?.screen.scale ?? 3 let preferredSize = CGSize( @@ -129,7 +129,7 @@ final class MediaViewerPageControlBar: UIView { ) let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailWith: pageID, + thumbnailWith: mediaIdentifier, filling: preferredSize ) cell.configure(with: thumbnailSource) @@ -191,10 +191,13 @@ final class MediaViewerPageControlBar: UIView { // MARK: - Methods - func configure(pageIDs: [MediaViewerPageID], currentPage: Int) { - var snapshot = NSDiffableDataSourceSnapshot() + func configure( + mediaIdentifiers: [AnyMediaIdentifier], + currentPage: Int + ) { + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) - snapshot.appendItems(pageIDs) + snapshot.appendItems(mediaIdentifiers) diffableDataSource.apply(snapshot) { let indexPath = IndexPath(item: currentPage, section: 0) @@ -206,16 +209,16 @@ final class MediaViewerPageControlBar: UIView { } } - private func page(with pageID: MediaViewerPageID) -> Int? { - diffableDataSource.snapshot().indexOfItem(pageID) + private func page(with identifier: AnyMediaIdentifier) -> Int? { + diffableDataSource.snapshot().indexOfItem(identifier) } - private func pageID(forPage page: Int) -> MediaViewerPageID { + private func mediaIdentifier(forPage page: Int) -> AnyMediaIdentifier { diffableDataSource.snapshot().itemIdentifiers[page] } - private func cell(for pageID: MediaViewerPageID) -> PageControlBarThumbnailCell? { - guard let indexPath = diffableDataSource.indexPath(for: pageID), + private func cell(for identifier: AnyMediaIdentifier) -> PageControlBarThumbnailCell? { + guard let indexPath = diffableDataSource.indexPath(for: identifier), let cell = collectionView.cellForItem(at: indexPath) else { return nil } @@ -293,9 +296,9 @@ final class MediaViewerPageControlBar: UIView { private func correctExpandingItemAspectRatioIfNeeded() { guard let indexPathForCurrentCenterItem, let dataSource else { return } let page = indexPathForCurrentCenterItem.item - let pageID = pageID(forPage: page) + let identifier = mediaIdentifier(forPage: page) - if let thumbnailWidthToHeight = dataSource.mediaViewerPageControlBar(self, widthToHeightOfThumbnailWith: pageID) { + if let thumbnailWidthToHeight = dataSource.mediaViewerPageControlBar(self, widthToHeightOfThumbnailWith: identifier) { expandAndScrollToItem( at: indexPathForCurrentCenterItem, causingBy: nil, @@ -307,7 +310,7 @@ final class MediaViewerPageControlBar: UIView { let thumbnailSource = dataSource.mediaViewerPageControlBar( self, - thumbnailWith: pageID, + thumbnailWith: identifier, filling: .init(width: 100, height: 100) ) switch thumbnailSource { @@ -376,10 +379,12 @@ extension MediaViewerPageControlBar { return } - let destinationPageID = pageID(forPage: destinationPage) + let destinationIdentifier = mediaIdentifier( + forPage: destinationPage + ) let expandingThumbnailWidthToHeight = dataSource?.mediaViewerPageControlBar( self, - widthToHeightOfThumbnailWith: destinationPageID + widthToHeightOfThumbnailWith: destinationIdentifier ) let style: MediaViewerPageControlBarLayout.Style = .expanded( IndexPath(item: destinationPage, section: 0), From 2b0901a244457963622d8ec9f44ce6ec44cc8c22 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 00:34:31 +0900 Subject: [PATCH 026/141] rename: previous/nextIdentifier(of:) => identifier(before/after:) --- Sources/MediaViewer/MediaViewerViewController.swift | 4 ++-- Sources/MediaViewer/MediaViewerViewModel.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 3ab49031..b151a677 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -480,7 +480,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - guard let previousIdentifier = mediaViewerVM.previousMediaIdentifier(of: mediaViewerPageVC.mediaIdentifier) else { + guard let previousIdentifier = mediaViewerVM.mediaIdentifier(before: mediaViewerPageVC.mediaIdentifier) else { return nil } return makeMediaViewerPage(with: previousIdentifier) @@ -494,7 +494,7 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { assertionFailure("Unknown view controller: \(viewController)") return nil } - guard let nextIdentifier = mediaViewerVM.nextMediaIdentifier(of: mediaViewerPageVC.mediaIdentifier) else { + guard let nextIdentifier = mediaViewerVM.mediaIdentifier(after: mediaViewerPageVC.mediaIdentifier) else { return nil } return makeMediaViewerPage(with: nextIdentifier) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index b5fb81bf..177c946b 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -27,16 +27,16 @@ final class MediaViewerViewModel: ObservableObject { mediaIdentifiers.firstIndex(of: identifier) } - func previousMediaIdentifier( - of identifier: AnyMediaIdentifier + func mediaIdentifier( + before identifier: AnyMediaIdentifier ) -> AnyMediaIdentifier? { guard let page = page(with: identifier) else { return nil } let previousPage = page - 1 return mediaIdentifier(forPage: previousPage) } - func nextMediaIdentifier( - of identifier: AnyMediaIdentifier + func mediaIdentifier( + after identifier: AnyMediaIdentifier ) -> AnyMediaIdentifier? { guard let page = page(with: identifier) else { return nil } let nextPage = page + 1 From 881a0e1c065e6f8c5efcac21b661ddb39f89fd20 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 02:16:12 +0900 Subject: [PATCH 027/141] change: transitionSourceView(forCurrentMediaOf:) => mediaViewer(_:transitionSourceViewForMediaWith:) --- .../Camera/CameraLikeViewController.swift | 5 +++-- .../Grid/AsyncImagesViewController.swift | 8 ++++---- .../Grid/SyncImagesViewController.swift | 8 ++++---- .../MediaViewer/MediaViewerDataSource.swift | 19 ++++++++++++++++--- .../MediaViewerViewController.swift | 10 ++++++---- 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift index b406c7da..a7158310 100644 --- a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift @@ -135,8 +135,9 @@ extension CameraLikeViewController: MediaViewerDataSource { return size.width / size.height } - func transitionSourceView( - forCurrentMediaOf mediaViewer: MediaViewerViewController + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: PHAsset ) -> UIView? { cameraLikeView.showLibraryButton } diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index fe9e500d..893cee51 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -196,11 +196,11 @@ extension AsyncImagesViewController: MediaViewerDataSource { } } - func transitionSourceView( - forCurrentMediaOf mediaViewer: MediaViewerViewController + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: PHAsset ) -> UIView? { - let currentPage = mediaViewer.currentPage - let indexPathForCurrentImage = IndexPath(item: currentPage, section: 0) + let indexPathForCurrentImage = dataSource.indexPath(for: mediaIdentifier)! let collectionView = imageGridView.collectionView diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index c72e0c71..06d72eb1 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -86,11 +86,11 @@ extension SyncImagesViewController: MediaViewerDataSource { .sync(mediaIdentifier) } - func transitionSourceView( - forCurrentMediaOf mediaViewer: MediaViewerViewController + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: UIImage ) -> UIView? { - let currentPage = mediaViewer.currentPage - let indexPathForCurrentImage = IndexPath(item: currentPage, section: 0) + let indexPathForCurrentImage = dataSource.indexPath(for: mediaIdentifier)! let collectionView = imageGridView.collectionView diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index fdc5d241..b8011849 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -64,10 +64,13 @@ public protocol MediaViewerDataSource: AnyObject { /// /// If `nil`, the animation looks like cross-dissolve. /// - /// - Parameter mediaViewer: An object representing the media viewer requesting this information. + /// - Parameters: + /// - mediaViewer: An object representing the media viewer requesting this information. + /// - mediaIdentifier: An identifier for the current viewing media. /// - Returns: The transition source view for current media of `mediaViewer`. - func transitionSourceView( - forCurrentMediaOf mediaViewer: MediaViewerViewController + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: MediaIdentifier ) -> UIView? /// Asks the data source to return the transition source image for current media of the viewer. @@ -172,4 +175,14 @@ extension MediaViewerDataSource { filling: preferredThumbnailSize ) } + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: AnyMediaIdentifier + ) -> UIView? { + self.mediaViewer( + mediaViewer, + transitionSourceViewForMediaWith: mediaIdentifier.rawValue as! MediaIdentifier + ) + } } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b151a677..c26043ee 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -408,8 +408,9 @@ open class MediaViewerViewController: UIPageViewController { private func panned(recognizer: UIPanGestureRecognizer) { if recognizer.state == .began { // Start the interactive pop transition - let sourceView = mediaViewerDataSource?.transitionSourceView( - forCurrentMediaOf: self + let sourceView = mediaViewerDataSource?.mediaViewer( + self, + transitionSourceViewForMediaWith: currentMediaIdentifier ) interactivePopTransition = .init(sourceView: sourceView) @@ -583,8 +584,9 @@ extension MediaViewerViewController: UINavigationControllerDelegate { willBeginPopTransitionTo: toVC ) } - let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource?.transitionSourceView( - forCurrentMediaOf: self + let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource?.mediaViewer( + self, + transitionSourceViewForMediaWith: currentMediaIdentifier ) return MediaViewerTransition( operation: operation, From 27249823a11a42df9612e886ecb461da62799272 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 02:34:55 +0900 Subject: [PATCH 028/141] change: MediaViewerDelegate to handle media identifiers --- .../Grid/AsyncImagesViewController.swift | 8 +++-- .../MediaViewer/MediaViewerDataSource.swift | 2 +- Sources/MediaViewer/MediaViewerDelegate.swift | 30 +++++++++++++++---- .../MediaViewerViewController.swift | 13 ++++++-- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 893cee51..c9b9e4fb 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -225,9 +225,11 @@ extension AsyncImagesViewController: MediaViewerDataSource { extension AsyncImagesViewController: MediaViewerDelegate { - func mediaViewer(_ mediaViewer: MediaViewerViewController, didMoveToPage page: Int) { - let asset = dataSource.snapshot().itemIdentifiers[page] - let dateDescription = asset.creationDate?.formatted() + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + didMoveToMediaWith mediaIdentifier: PHAsset + ) { + let dateDescription = mediaIdentifier.creationDate?.formatted() mediaViewer.title = dateDescription } } diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index b8011849..0a2c2aaa 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -140,7 +140,7 @@ extension MediaViewerDataSource { } } -// MARK: - Open existential types - +// MARK: - Type erasure support - extension MediaViewerDataSource { diff --git a/Sources/MediaViewer/MediaViewerDelegate.swift b/Sources/MediaViewer/MediaViewerDelegate.swift index 8bf0a060..53376027 100644 --- a/Sources/MediaViewer/MediaViewerDelegate.swift +++ b/Sources/MediaViewer/MediaViewerDelegate.swift @@ -8,7 +8,9 @@ import UIKit @MainActor -public protocol MediaViewerDelegate: AnyObject { +public protocol MediaViewerDelegate: AnyObject { + + associatedtype MediaIdentifier: Hashable /// Notifies the delegate before a media viewer is popped from the navigation controller. /// - Parameters: @@ -19,11 +21,14 @@ public protocol MediaViewerDelegate: AnyObject { willBeginPopTransitionTo destinationVC: UIViewController ) - /// Tells the delegate a media viewer has moved to a particular page. + /// Tells the delegate a media viewer has moved to some media page. /// - Parameters: /// - mediaViewer: A media viewer informing the delegate about the page move. - /// - page: A destination page. - func mediaViewer(_ mediaViewer: MediaViewerViewController, didMoveToPage page: Int) + /// - mediaIdentifier: An identifier for media on a destination page. + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + didMoveToMediaWith mediaIdentifier: MediaIdentifier + ) } // MARK: - Default implementations - @@ -37,6 +42,21 @@ extension MediaViewerDelegate { public func mediaViewer( _ mediaViewer: MediaViewerViewController, - didMoveToPage page: Int + didMoveToMediaWith mediaIdentifier: MediaIdentifier ) {} } + +// MARK: - Type erasure support - + +extension MediaViewerDelegate { + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + didMoveToMediaWith mediaIdentifier: AnyMediaIdentifier + ) { + self.mediaViewer( + mediaViewer, + didMoveToMediaWith: mediaIdentifier.rawValue as! MediaIdentifier + ) + } +} diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index c26043ee..52bc9d62 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -41,6 +41,9 @@ open class MediaViewerViewController: UIPageViewController { open weak var mediaViewerDataSource: (any MediaViewerDataSource)? /// The object that acts as the delegate of the media viewer. + /// + /// - Precondition: The associated type `MediaIdentifier` must be the same as + /// the one of `mediaViewerDataSource`. open weak var mediaViewerDelegate: (any MediaViewerDelegate)? /// The current page of the media viewer. @@ -179,7 +182,10 @@ open class MediaViewerViewController: UIPageViewController { * but since the delegate has not yet been set by the caller, * it needs to be told to the caller again at this time. */ - mediaViewerDelegate?.mediaViewer(self, didMoveToPage: currentPage) + mediaViewerDelegate?.mediaViewer( + self, + didMoveToMediaWith: currentMediaIdentifier + ) } private func setUpViews() { @@ -375,7 +381,10 @@ open class MediaViewerViewController: UIPageViewController { } private func pageDidChange() { - mediaViewerDelegate?.mediaViewer(self, didMoveToPage: currentPage) + mediaViewerDelegate?.mediaViewer( + self, + didMoveToMediaWith: currentMediaIdentifier + ) } private func handleContentOffsetChange() { From 0193ba9afa0daade42c6944e4db509d2feb1e9bd Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 03:00:23 +0900 Subject: [PATCH 029/141] update: verify the media identifier type of the delegate --- Sources/MediaViewer/MediaViewerDelegate.swift | 9 +++++++++ Sources/MediaViewer/MediaViewerViewController.swift | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerDelegate.swift b/Sources/MediaViewer/MediaViewerDelegate.swift index 53376027..7a44ac7d 100644 --- a/Sources/MediaViewer/MediaViewerDelegate.swift +++ b/Sources/MediaViewer/MediaViewerDelegate.swift @@ -50,6 +50,15 @@ extension MediaViewerDelegate { extension MediaViewerDelegate { + func verifyMediaIdentifierTypeIsSame( + as dataSource: some MediaViewerDataSource + ) { + precondition( + MediaIdentifier.self == DataSourceMediaIdentifier.self, + "`MediaIdentifier` must be \(DataSourceMediaIdentifier.self), the same as the data source, but it is actually \(MediaIdentifier.self)." + ) + } + func mediaViewer( _ mediaViewer: MediaViewerViewController, didMoveToMediaWith mediaIdentifier: AnyMediaIdentifier diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 52bc9d62..0d6b7a8d 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -44,7 +44,12 @@ open class MediaViewerViewController: UIPageViewController { /// /// - Precondition: The associated type `MediaIdentifier` must be the same as /// the one of `mediaViewerDataSource`. - open weak var mediaViewerDelegate: (any MediaViewerDelegate)? + open weak var mediaViewerDelegate: (any MediaViewerDelegate)? { + willSet { + guard let mediaViewerDataSource else { return } + newValue?.verifyMediaIdentifierTypeIsSame(as: mediaViewerDataSource) + } + } /// The current page of the media viewer. public var currentPage: Int { From 28894f43f333002933a27d61731c584a160c33e0 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 03:20:08 +0900 Subject: [PATCH 030/141] change: MediaViewerViewController.init(page:dataSource:) => .init(opening:dataSource:) --- .../Samples/Camera/CameraLikeViewController.swift | 7 +++++-- .../Samples/Grid/AsyncImagesViewController.swift | 3 ++- .../Samples/Grid/SyncImagesViewController.swift | 3 ++- Sources/MediaViewer/MediaViewerViewController.swift | 13 +++++++------ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift index a7158310..398b7c88 100644 --- a/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Camera/CameraLikeViewController.swift @@ -79,8 +79,11 @@ final class CameraLikeViewController: UIViewController { // MARK: - Methods private func showLibrary() { - guard !assets.isEmpty else { return } - let mediaViewer = MediaViewerViewController(page: assets.count - 1, dataSource: self) + guard let lastAsset = assets.last else { return } + let mediaViewer = MediaViewerViewController( + opening: lastAsset, + dataSource: self + ) navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) } diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index c9b9e4fb..bf3478c7 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -141,7 +141,8 @@ final class AsyncImagesViewController: UIViewController { extension AsyncImagesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let mediaViewer = MediaViewerViewController(page: indexPath.item, dataSource: self) + let asset = dataSource.itemIdentifier(for: indexPath)! + let mediaViewer = MediaViewerViewController(opening: asset, dataSource: self) mediaViewer.mediaViewerDelegate = self mediaViewer.toolbarItems = [ .init(image: .init(systemName: "square.and.arrow.up")), diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 06d72eb1..00ed130d 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -65,7 +65,8 @@ final class SyncImagesViewController: UIViewController { extension SyncImagesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let mediaViewer = MediaViewerViewController(page: indexPath.item, dataSource: self) + let image = dataSource.itemIdentifier(for: indexPath)! + let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 0d6b7a8d..353302da 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -128,9 +128,12 @@ open class MediaViewerViewController: UIPageViewController { /// Creates a new viewer. /// - Parameters: - /// - page: The page number of the media. + /// - mediaIdentifier: An identifier for media to view first. /// - dataSource: The data source for the viewer. - public init(page: Int, dataSource: some MediaViewerDataSource) { + public init( + opening mediaIdentifier: MediaIdentifier, + dataSource: some MediaViewerDataSource + ) { super.init( transitionStyle: .scroll, navigationOrientation: .horizontal, @@ -144,10 +147,8 @@ open class MediaViewerViewController: UIPageViewController { let identifiers = dataSource.mediaIdentifiers(for: self) mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) - guard let identifier = mediaViewerVM.mediaIdentifier(forPage: page), - let mediaViewerPage = makeMediaViewerPage(with: identifier) else { - preconditionFailure("Page \(page) out of range.") - } + let identifier = AnyMediaIdentifier(rawValue: mediaIdentifier) + let mediaViewerPage = makeMediaViewerPage(with: identifier)! setViewControllers([mediaViewerPage], direction: .forward, animated: false) hidesBottomBarWhenPushed = true From e8bdf81f9d5215f7b769516bb3d43e64c1ef31c5 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 03:22:30 +0900 Subject: [PATCH 031/141] chore: refactor --- .../MediaViewerViewController.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 353302da..291b362c 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -147,8 +147,10 @@ open class MediaViewerViewController: UIPageViewController { let identifiers = dataSource.mediaIdentifiers(for: self) mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) - let identifier = AnyMediaIdentifier(rawValue: mediaIdentifier) - let mediaViewerPage = makeMediaViewerPage(with: identifier)! + let mediaViewerPage = makeMediaViewerPage( + with: AnyMediaIdentifier(rawValue: mediaIdentifier), + dataSource: dataSource + ) setViewControllers([mediaViewerPage], direction: .forward, animated: false) hidesBottomBarWhenPushed = true @@ -517,15 +519,15 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { } private func makeMediaViewerPage( - with identifier: AnyMediaIdentifier - ) -> MediaViewerOnePageViewController? { - guard let mediaViewerDataSource else { return nil } - let media = mediaViewerDataSource.mediaViewer(self, mediaWith: identifier) - + with identifier: AnyMediaIdentifier, + dataSource: some MediaViewerDataSource + ) -> MediaViewerOnePageViewController { let mediaViewerPage = MediaViewerOnePageViewController( mediaIdentifier: identifier ) mediaViewerPage.delegate = self + + let media = dataSource.mediaViewer(self, mediaWith: identifier) switch media { case .image(.sync(let image)): mediaViewerPage.mediaViewerOnePageView.setImage(image, with: .none) @@ -537,6 +539,13 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { } return mediaViewerPage } + + private func makeMediaViewerPage( + with identifier: AnyMediaIdentifier + ) -> MediaViewerOnePageViewController? { + guard let mediaViewerDataSource else { return nil } + return makeMediaViewerPage(with: identifier, dataSource: mediaViewerDataSource) + } } // MARK: - MediaViewerPageControlBarDataSource - From 7c7992c35b2838f929af2acf4ddbf30daa3bcbb5 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 03:32:06 +0900 Subject: [PATCH 032/141] chore: add precondition --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 291b362c..97e8fd91 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -145,6 +145,11 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerDataSource = dataSource let identifiers = dataSource.mediaIdentifiers(for: self) + precondition( + identifiers.contains(mediaIdentifier), + "mediaIdentifier \(mediaIdentifier) must be included in identifiers returned by dataSource.mediaIdentifiers(for:)." + ) + mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) let mediaViewerPage = makeMediaViewerPage( From 229c7c011333b9486713219a2346a2bceec3431c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 03:34:51 +0900 Subject: [PATCH 033/141] change: MediaViewerPageControlBar.configure(...) to take identifier instead of page --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 97e8fd91..c7551456 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -213,7 +213,7 @@ open class MediaViewerViewController: UIPageViewController { pageControlBar.configure( mediaIdentifiers: mediaViewerVM.mediaIdentifiers, - currentPage: currentPage + currentIdentifier: currentMediaIdentifier ) pageControlToolbar.addSubview(pageControlBar) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 2f38ce16..b647443a 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -193,12 +193,13 @@ final class MediaViewerPageControlBar: UIView { func configure( mediaIdentifiers: [AnyMediaIdentifier], - currentPage: Int + currentIdentifier: AnyMediaIdentifier ) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(mediaIdentifiers) + let currentPage = snapshot.indexOfItem(currentIdentifier)! diffableDataSource.apply(snapshot) { let indexPath = IndexPath(item: currentPage, section: 0) self.expandAndScrollToItem( From 648939b2e711bc71c86abe797cee67a24fdc4da5 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 04:04:50 +0900 Subject: [PATCH 034/141] change: move(toPage:animated:) => move(toMediaWith:animated:completion:) --- .../MediaViewerViewController.swift | 41 +++++++++++++++---- .../MediaViewer/MediaViewerViewModel.swift | 10 +++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index c7551456..fe57ea06 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -282,9 +282,11 @@ open class MediaViewerViewController: UIPageViewController { pageControlBar.pageDidChange .sink { [weak self] page, reason in + guard let self else { return } switch reason { case .tapOnPageThumbnail, .scrollingBar: - self?.move(toPage: page, animated: false) + let identifier = mediaViewerVM.mediaIdentifier(forPage: page)! + move(toMediaWith: identifier, animated: false) case .configuration, .interactivePaging: // Do nothing because it has already been moved to the page. break @@ -379,17 +381,38 @@ open class MediaViewerViewController: UIPageViewController { // MARK: - Methods - /// Move to show media on the specified page. - /// - Parameter page: The destination page. - open func move(toPage page: Int, animated: Bool) { - guard let identifier = mediaViewerVM.mediaIdentifier(forPage: page) else { - preconditionFailure("Page \(page) out of range.") - } + /// Move to media with the specified identifier. + /// - Parameters: + /// - identifier: An identifier for destination media. + /// - animated: A Boolean value that indicates whether the transition is to be animated. + /// - completion: A closure to be called when the animation completes. + /// It takes a boolean value whether the transition is finished or not. + open func move( + toMediaWith identifier: MediaIdentifier, + animated: Bool, + completion: ((Bool) -> Void)? = nil + ) where MediaIdentifier: Hashable { + self.move( + toMediaWith: AnyMediaIdentifier(rawValue: identifier), + animated: animated, + completion: completion + ) + } + + func move( + toMediaWith identifier: AnyMediaIdentifier, + animated: Bool, + completion: ((Bool) -> Void)? = nil + ) { guard let mediaViewerPage = makeMediaViewerPage(with: identifier) else { return } setViewControllers( [mediaViewerPage], - direction: page < currentPage ? .reverse : .forward, - animated: animated + direction: mediaViewerVM.moveDirection( + from: currentMediaIdentifier, + to: identifier + ), + animated: animated, + completion: completion ) } diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 177c946b..0e72af31 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -6,6 +6,7 @@ // import Combine +import class UIKit.UIPageViewController final class MediaViewerViewModel: ObservableObject { @@ -42,4 +43,13 @@ final class MediaViewerViewModel: ObservableObject { let nextPage = page + 1 return mediaIdentifier(forPage: nextPage) } + + func moveDirection( + from currentIdentifier: AnyMediaIdentifier, + to destinationIdentifier: AnyMediaIdentifier + ) -> UIPageViewController.NavigationDirection { + let currentPage = page(with: currentIdentifier)! + let destinationPage = page(with: destinationIdentifier)! + return destinationPage < currentPage ? .reverse : .forward + } } From 5e9d8e1f385954ef0c5dcda2cd022ea4d51e748b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 04:06:44 +0900 Subject: [PATCH 035/141] change: deprecate MediaViewerViewController.currentPage --- Sources/MediaViewer/MediaViewerViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index fe57ea06..05bb6f67 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -52,6 +52,7 @@ open class MediaViewerViewController: UIPageViewController { } /// The current page of the media viewer. + @available(*, deprecated) public var currentPage: Int { mediaViewerVM.page(with: currentMediaIdentifier)! } From 6509d668db3eb7c68e36d0a8781a411fed543fb3 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 04:08:21 +0900 Subject: [PATCH 036/141] change: mediaViewerDataSource.setter to be private --- Sources/MediaViewer/MediaViewerViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 05bb6f67..4501ce40 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -38,7 +38,9 @@ open class MediaViewerViewController: UIPageViewController { private var cancellables: Set = [] /// The data source of the media viewer object. - open weak var mediaViewerDataSource: (any MediaViewerDataSource)? + /// + /// - Note: This data source object must be set at object creation time and may not be changed. + open private(set) weak var mediaViewerDataSource: (any MediaViewerDataSource)? /// The object that acts as the delegate of the media viewer. /// From b66401dcc5040256d7dc9264e290198763a5941e Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 19 Nov 2023 04:11:56 +0900 Subject: [PATCH 037/141] docs: improve doc comment --- Sources/MediaViewer/MediaViewerDataSource.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index 0a2c2aaa..9d0a8700 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -33,7 +33,8 @@ public protocol MediaViewerDataSource: AnyObject { /// Asks the data source to return an aspect ratio of media with the specified identifier. /// - /// The ratio will be used to determine a size of page thumbnail. + /// The aspect ratio is calculated by dividing the media width by the height. + /// It will be used to determine a size of page thumbnail. /// This method should return immediately. /// /// - Parameters: From c265b2827caf37d7d35dd436dc7dee732e3dc65a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 20 Nov 2023 00:11:44 +0900 Subject: [PATCH 038/141] change: make MediaViewer.init(coder:) unavailable --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 4501ce40..7c33d972 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -164,8 +164,9 @@ open class MediaViewerViewController: UIPageViewController { hidesBottomBarWhenPushed = true } - required public init?(coder: NSCoder) { - super.init(coder: coder) + @available(*, unavailable, message: "init(coder:) is not supported.") + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle From f63f0e5df1d90342f1d8bea54e30a2d74915036b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 20 Nov 2023 00:12:40 +0900 Subject: [PATCH 039/141] change: mediaViewerDataSource to be implicitly unwrapped optional --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 7c33d972..65da70a1 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -40,7 +40,7 @@ open class MediaViewerViewController: UIPageViewController { /// The data source of the media viewer object. /// /// - Note: This data source object must be set at object creation time and may not be changed. - open private(set) weak var mediaViewerDataSource: (any MediaViewerDataSource)? + open private(set) weak var mediaViewerDataSource: (any MediaViewerDataSource)! /// The object that acts as the delegate of the media viewer. /// From 99aa671abe71f128fd5a81b294c923ccacfa882b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 20 Nov 2023 00:15:41 +0900 Subject: [PATCH 040/141] remove: explicit unwrapping --- Sources/MediaViewer/MediaViewerViewController.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 65da70a1..b8bddbd4 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -457,7 +457,7 @@ open class MediaViewerViewController: UIPageViewController { private func panned(recognizer: UIPanGestureRecognizer) { if recognizer.state == .began { // Start the interactive pop transition - let sourceView = mediaViewerDataSource?.mediaViewer( + let sourceView = mediaViewerDataSource.mediaViewer( self, transitionSourceViewForMediaWith: currentMediaIdentifier ) @@ -589,8 +589,7 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { thumbnailWith mediaIdentifier: AnyMediaIdentifier, filling preferredThumbnailSize: CGSize ) -> Source { - guard let mediaViewerDataSource else { return .none } - return mediaViewerDataSource.mediaViewer( + mediaViewerDataSource.mediaViewer( self, pageThumbnailForMediaWith: mediaIdentifier, filling: preferredThumbnailSize @@ -601,7 +600,7 @@ extension MediaViewerViewController: MediaViewerPageControlBarDataSource { _ pageControlBar: MediaViewerPageControlBar, widthToHeightOfThumbnailWith mediaIdentifier: AnyMediaIdentifier ) -> CGFloat? { - mediaViewerDataSource?.mediaViewer( + mediaViewerDataSource.mediaViewer( self, widthToHeightOfMediaWith: mediaIdentifier ) @@ -640,7 +639,7 @@ extension MediaViewerViewController: UINavigationControllerDelegate { willBeginPopTransitionTo: toVC ) } - let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource?.mediaViewer( + let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource.mediaViewer( self, transitionSourceViewForMediaWith: currentMediaIdentifier ) @@ -649,7 +648,7 @@ extension MediaViewerViewController: UINavigationControllerDelegate { sourceView: sourceView, sourceImage: { [weak self] in guard let self else { return nil } - return mediaViewerDataSource?.mediaViewer( + return mediaViewerDataSource.mediaViewer( self, transitionSourceImageWith: sourceView ) From 469255efaf6d17cff16db020ccdf960233259147 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 7 Nov 2023 22:02:43 +0900 Subject: [PATCH 041/141] add: PageControlBarThumbnailCell.performDeleteAnimationBody() --- .../PageControlBar/PageControlBarThumbnailCell.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift index f8a7d874..aaa992d9 100644 --- a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift +++ b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift @@ -50,6 +50,10 @@ final class PageControlBarThumbnailCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() + // Reset changes by delete animation. + transform = .identity + alpha = 1 + imageLoadingTask?.cancel() imageLoadingTask = nil imageView.image = nil @@ -60,4 +64,10 @@ final class PageControlBarThumbnailCell: UICollectionViewCell { func configure(with imageSource: Source) { imageLoadingTask = imageView.load(imageSource) } + + func performDeleteAnimationBody() { + // NOTE: These changes are reset in prepareForReuse(). + transform = transform.scaledBy(x: 0.5, y: 0.5) + alpha = 0 + } } From 5567fe67d65fe9f7005b75dfc093dfb3ce1fd81a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 7 Nov 2023 22:06:19 +0900 Subject: [PATCH 042/141] add: MediaViewerOnePageView.performDeleteAnimationBody() --- .../MediaViewerOnePage/MediaViewerOnePageView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift index 95f4d875..a0c0d25c 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift @@ -160,6 +160,11 @@ final class MediaViewerOnePageView: UIView { scrollView.zoom(to: zoomArea, animated: animated) } + func performDeleteAnimationBody() { + imageView.transform = imageView.transform.scaledBy(x: 0.5, y: 0.5) + imageView.alpha = 0 + } + func destroyLayoutConfigurationBeforeTransition() { NSLayoutConstraint.deactivate(constraintsBasedOnImageSize) imageView.translatesAutoresizingMaskIntoConstraints = true From 63cf233f14c96b7145bd7d0f0fb6b6afa394b1b7 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 02:40:55 +0900 Subject: [PATCH 043/141] add: MediaViewerPageControlBar.deleteItems(_:animated:) --- .../PageControlBar/MediaViewerPageControlBar.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index b647443a..be1355d5 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -363,6 +363,17 @@ final class MediaViewerPageControlBar: UIView { } } +// MARK: - Deletion - + +extension MediaViewerPageControlBar { + + func deleteItems(_ identifiers: [AnyMediaIdentifier], animated: Bool) { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems(identifiers) + diffableDataSource.apply(snapshot, animatingDifferences: animated) + } +} + // MARK: - Interactive paging - extension MediaViewerPageControlBar { From a42ae07c9e603eb1976dd5d519c3ca5272f83dd8 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 03:07:07 +0900 Subject: [PATCH 044/141] add: MediaViewerPageControlBar.performDeleteAnimationBody(for:) --- .../PageControlBar/MediaViewerPageControlBar.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index be1355d5..f8bad7b9 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -367,6 +367,10 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { + func performDeleteAnimationBody(for identifier: AnyMediaIdentifier) { + cell(for: identifier)?.performDeleteAnimationBody() + } + func deleteItems(_ identifiers: [AnyMediaIdentifier], animated: Bool) { var snapshot = diffableDataSource.snapshot() snapshot.deleteItems(identifiers) From a272a2fb63cfb197587cfb63f8c18cd1197cf355 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 03:09:09 +0900 Subject: [PATCH 045/141] add: MediaViewerViewController.deleteMedia(with:after:) --- .../MediaViewerViewController.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b8bddbd4..32ab05d4 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -420,6 +420,44 @@ open class MediaViewerViewController: UIPageViewController { ) } + open func deleteMedia( + with identifier: MediaIdentifier, + after deleteAction: () async throws -> Void + ) async rethrows where MediaIdentifier: Hashable { + let identifier = AnyMediaIdentifier(rawValue: identifier) + let isDeletingLastPage = identifier == mediaViewerVM.mediaIdentifiers.last + let isDeletingCurrentPage = identifier == currentMediaIdentifier + + try await deleteAction() + + // Perform delete animation + let deletionAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { + if isDeletingCurrentPage { + let currentPageView = self.currentPageViewController.mediaViewerOnePageView + currentPageView.performDeleteAnimationBody() + } + self.pageControlBar.performDeleteAnimationBody(for: identifier) + } + deletionAnimator.startAnimation() + await deletionAnimator.addCompletion() + + // TODO: Handle empty + // Reload data source + let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { + self.pageControlBar.deleteItems([identifier], animated: true) + + // Move page if deleted an image on the current page + if isDeletingCurrentPage { + let destinationIdentifier = isDeletingLastPage + ? self.mediaViewerVM.mediaIdentifier(before: identifier)! + : self.mediaViewerVM.mediaIdentifier(after: identifier)! + self.move(toMediaWith: destinationIdentifier, animated: true) + } + } + finishAnimator.startAnimation() + await finishAnimator.addCompletion() + } + private func pageDidChange() { mediaViewerDelegate?.mediaViewer( self, From 59b44b2ebf425bd2560d1c65e8164069cc98e016 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 04:25:27 +0900 Subject: [PATCH 046/141] add: MediaViewerPageControlBar.State.deleting --- Sources/MediaViewer/MediaViewerViewController.swift | 2 ++ .../PageControlBar/MediaViewerPageControlBar.swift | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 32ab05d4..3b363fc6 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -486,6 +486,8 @@ open class MediaViewerViewController: UIPageViewController { if progress != 0 { pageControlBar.startInteractivePaging(forwards: isMovingToNextPage) } + case .deleting: + break } } diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index f8bad7b9..314909e7 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -38,6 +38,8 @@ final class MediaViewerPageControlBar: UIView { /// The state of interactively transitioning between pages. case transitioningInteractively(UICollectionViewTransitionLayout, forwards: Bool) + case deleting + var indexPathForFinalDestinationItem: IndexPath? { guard case .collapsed(let indexPath) = self else { return nil } return indexPath @@ -491,7 +493,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { !isEdgeIndexPath(indexPathForCurrentCenterItem) { expandAndScrollToCenterItem(animated: true, causingBy: .scrollingBar) } - case .collapsing, .expanding, .expanded, .transitioningInteractively: + case .collapsing, .expanding, .expanded, .transitioningInteractively, .deleting: break } } @@ -540,7 +542,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { switch state { - case .collapsing, .collapsed: + case .collapsing, .collapsed, .deleting: expandAndScrollToCenterItem(animated: true, causingBy: .scrollingBar) case .expanding, .expanded, .transitioningInteractively: break // NOP From 5006f94c5ed033f3d380c404ecbbf79f772f1b4a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 04:31:44 +0900 Subject: [PATCH 047/141] change: state to .deleting during deletion --- Sources/MediaViewer/MediaViewerViewController.swift | 3 +++ .../PageControlBar/MediaViewerPageControlBar.swift | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 3b363fc6..f935c5c9 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -430,6 +430,9 @@ open class MediaViewerViewController: UIPageViewController { try await deleteAction() + pageControlBar.beginDeletion() + defer { pageControlBar.finishDeletion() } + // Perform delete animation let deletionAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { if isDeletingCurrentPage { diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 314909e7..3ae4ead9 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -369,11 +369,23 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { + func beginDeletion() { + state = .deleting + } + + func finishDeletion() { + state = .expanded + } + func performDeleteAnimationBody(for identifier: AnyMediaIdentifier) { + assert(state == .deleting) + cell(for: identifier)?.performDeleteAnimationBody() } func deleteItems(_ identifiers: [AnyMediaIdentifier], animated: Bool) { + assert(state == .deleting) + var snapshot = diffableDataSource.snapshot() snapshot.deleteItems(identifiers) diffableDataSource.apply(snapshot, animatingDifferences: animated) From 9b0e62c7ff1016b7a84dd70bf1ffafd8642ea1c8 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 21:00:22 +0900 Subject: [PATCH 048/141] add: precondition to confirm that media has been deleted --- Sources/MediaViewer/MediaViewerViewController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index f935c5c9..5d8cc74e 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -430,6 +430,12 @@ open class MediaViewerViewController: UIPageViewController { try await deleteAction() + let identifiersAfterDeletion = mediaViewerDataSource!.mediaIdentifiers(for: self) + precondition( + identifiersAfterDeletion.count == mediaViewerVM.mediaIdentifiers.count - 1, + "You have to complete deletion in `deleteAction` closure." + ) + pageControlBar.beginDeletion() defer { pageControlBar.finishDeletion() } From f9be6123988ed90feac4974cf97a274e17f43db8 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 21:12:42 +0900 Subject: [PATCH 049/141] add: prevent deletion if not ready --- Sources/MediaViewer/MediaViewerViewController.swift | 12 ++++++++---- .../PageControlBar/MediaViewerPageControlBar.swift | 5 ++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 5d8cc74e..974c5a24 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -420,10 +420,17 @@ open class MediaViewerViewController: UIPageViewController { ) } + public enum DeletionError: Error { + case notReadyToDelete + } + open func deleteMedia( with identifier: MediaIdentifier, after deleteAction: () async throws -> Void - ) async rethrows where MediaIdentifier: Hashable { + ) async throws where MediaIdentifier: Hashable { + try pageControlBar.beginDeletion() + defer { pageControlBar.finishDeletion() } + let identifier = AnyMediaIdentifier(rawValue: identifier) let isDeletingLastPage = identifier == mediaViewerVM.mediaIdentifiers.last let isDeletingCurrentPage = identifier == currentMediaIdentifier @@ -436,9 +443,6 @@ open class MediaViewerViewController: UIPageViewController { "You have to complete deletion in `deleteAction` closure." ) - pageControlBar.beginDeletion() - defer { pageControlBar.finishDeletion() } - // Perform delete animation let deletionAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { if isDeletingCurrentPage { diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 3ae4ead9..078f98de 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -369,7 +369,10 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { - func beginDeletion() { + func beginDeletion() throws { + guard state == .expanded else { + throw MediaViewerViewController.DeletionError.notReadyToDelete + } state = .deleting } From 621b6038a307fdf7b076efc92c9974e6d0a137b2 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 22:00:06 +0900 Subject: [PATCH 050/141] add: MediaViewerViewController.deleteCurrentMedia(after:) --- Sources/MediaViewer/MediaViewerViewController.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 974c5a24..ec856686 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -471,6 +471,17 @@ open class MediaViewerViewController: UIPageViewController { await finishAnimator.addCompletion() } + open func deleteCurrentMedia( + after deleteAction: ( + _ currentMediaIdentifier: MediaIdentifier + ) async throws -> Void + ) async throws where MediaIdentifier: Hashable { + let currentIdentifier = self.currentMediaIdentifier.rawValue as! MediaIdentifier + try await deleteMedia(with: currentIdentifier, after: { + try await deleteAction(currentIdentifier) + }) + } + private func pageDidChange() { mediaViewerDelegate?.mediaViewer( self, From ffa27f1c5e9b796b759b88d01c9db74baecb8088 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 22:32:05 +0900 Subject: [PATCH 051/141] docs: add doc comments --- .../MediaViewerViewController.swift | 44 +++++++++++++++++++ .../MediaViewerPageControlBar.swift | 14 ++++++ 2 files changed, 58 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index ec856686..db2febbf 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -420,10 +420,35 @@ open class MediaViewerViewController: UIPageViewController { ) } + /// An error on media deletion. public enum DeletionError: Error { + + /// An error that indicates the media viewer is not ready to delete media. + /// + /// It is thrown when the viewer is unsettled, e.g. during paging or delete animation. case notReadyToDelete } + /// Deletes media with the specified identifier. + /// + /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. + /// + /// ```swift + /// try mediaViewer.deleteMedia(with: imageIdentifier, after: { + /// try await your.deleteImage(with: imageIdentifier) + /// }) + /// ``` + /// + /// - Note: `deleteAction` must complete deletion until it returns. + /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. + /// If the deletion fails, `deleteAction` must throw an error. + /// - Parameters: + /// - identifier: An identifier for media to delete. + /// - deleteAction: A closure that performs the actual media deletion. + /// It must complete deletion until it returns. + /// - Throws: If the viewer is not ready to delete (e.g. during paging or delete animation), + /// `DeletionError.notReadyToDelete` will be thrown. + /// If `deleteAction` throws some error, it will be thrown. open func deleteMedia( with identifier: MediaIdentifier, after deleteAction: () async throws -> Void @@ -471,6 +496,25 @@ open class MediaViewerViewController: UIPageViewController { await finishAnimator.addCompletion() } + /// Deletes media on the current page. + /// + /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. + /// + /// ```swift + /// try mediaViewer.deleteCurrentMedia(after: { currentImageIdentifier in + /// try await your.deleteImage(with: currentImageIdentifier) + /// }) + /// ``` + /// + /// - Note: `deleteAction` must complete deletion until it returns. + /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. + /// If the deletion fails, `deleteAction` must throw an error. + /// - Parameter deleteAction: A closure that takes the current media identifier and + /// performs the actual media deletion. + /// It must complete deletion until it returns. + /// - Throws: If the viewer is not ready to delete (e.g. during paging or delete animation), + /// `DeletionError.notReadyToDelete` will be thrown. + /// If `deleteAction` throws some error, it will be thrown. open func deleteCurrentMedia( after deleteAction: ( _ currentMediaIdentifier: MediaIdentifier diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 078f98de..b8fb1cc0 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -380,12 +380,26 @@ extension MediaViewerPageControlBar { state = .expanded } + /// Performs the body of the delete animation. + /// + /// This method itself does not animate, so call it in an animation block. + /// It also does not update the data source so you have to call + /// `deleteItems(_:animated:)` after this animation is finished. + /// + /// - Parameter identifier: An identifier for media to perform delete animation. func performDeleteAnimationBody(for identifier: AnyMediaIdentifier) { assert(state == .deleting) cell(for: identifier)?.performDeleteAnimationBody() } + /// Deletes specified items. + /// + /// This method updates the data source. + /// + /// - Parameters: + /// - identifiers: Identifiers for media to delete. + /// - animated: Whether to animate the deletion. func deleteItems(_ identifiers: [AnyMediaIdentifier], animated: Bool) { assert(state == .deleting) From f6766b2543f49cf520765fd307567e3139985726 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 23:10:03 +0900 Subject: [PATCH 052/141] add: MediaViewerViewController.trashButton(...) --- .../MediaViewerViewController+UI.swift | 40 +++++++++++++++++++ .../MediaViewerViewController.swift | 2 + 2 files changed, 42 insertions(+) create mode 100644 Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift new file mode 100644 index 00000000..a7fc6a90 --- /dev/null +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -0,0 +1,40 @@ +// +// MediaViewerViewController+UI.swift +// +// +// Created by Yusaku Nishi on 2023/11/08. +// + +import UIKit + +extension MediaViewerViewController { + + /// Creates and returns a trash button for deleting media on the current page. + /// + /// If you want to provide your custom delete UI, you can build one with `deleteCurrentMedia(after:)` or `deleteMedia(with:after:)` instead. + /// + /// - Note: `deleteAction` must complete deletion until it returns. + /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. + /// If the deletion fails, `deleteAction` must throw an error. + /// - Parameter deleteAction: A closure that takes the current media identifier and + /// performs the actual media deletion. + /// It must complete deletion until it returns. + /// - Returns: A trash button for deleting media. + public func trashButton( + deleteAction: @escaping ( + _ currentMediaIdentifier: MediaIdentifier + ) async throws -> Void + ) -> UIBarButtonItem where MediaIdentifier: Hashable { + .init(systemItem: .trash, primaryAction: .init { [weak self] action in + guard let self else { return } + let button = action.sender as? UIBarButtonItem + button?.isEnabled = false + Task { + defer { button?.isEnabled = true } + try await self.deleteCurrentMedia(after: { currentMediaIdentifier in + try await deleteAction(currentMediaIdentifier) + }) + } + }) + } +} diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index db2febbf..06866ea2 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -506,6 +506,8 @@ open class MediaViewerViewController: UIPageViewController { /// }) /// ``` /// + /// If you want to provide the deletion UI in an easy way, you can use `trashButton(deleteAction:)` instead. + /// /// - Note: `deleteAction` must complete deletion until it returns. /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. /// If the deletion fails, `deleteAction` must throw an error. From 147b01d8e7d94e13703dc38d5d07f3e961fa6657 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 8 Nov 2023 23:21:04 +0900 Subject: [PATCH 053/141] add: trash button sample --- .../Samples/Grid/AsyncImagesViewController.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index bf3478c7..53a30b76 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -134,6 +134,13 @@ final class AsyncImagesViewController: UIViewController { await dataSource.apply(snapshot) } } + + // Fake removal (not actually delete photo) + private func removeAsset(_ asset: PHAsset) async { + var snapshot = dataSource.snapshot() + snapshot.deleteItems([asset]) + await dataSource.apply(snapshot, animatingDifferences: false) + } } // MARK: - UICollectionViewDelegate - @@ -151,7 +158,9 @@ extension AsyncImagesViewController: UICollectionViewDelegate { .flexibleSpace(), .init(image: .init(systemName: "info.circle")), .flexibleSpace(), - .init(systemItem: .trash) + mediaViewer.trashButton { currentMediaIdentifier in + await self.removeAsset(currentMediaIdentifier) + } ] navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) From 6b15651d3c15e549e63fc34e0e2c09905a573fd0 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 01:16:01 +0900 Subject: [PATCH 054/141] update: close the viewer when all media is deleted --- .../MediaViewerViewController.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 06866ea2..913e4acf 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -431,7 +431,7 @@ open class MediaViewerViewController: UIPageViewController { /// Deletes media with the specified identifier. /// - /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. + /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. If all media is deleted, the viewer will close. /// /// ```swift /// try mediaViewer.deleteMedia(with: imageIdentifier, after: { @@ -477,9 +477,15 @@ open class MediaViewerViewController: UIPageViewController { self.pageControlBar.performDeleteAnimationBody(for: identifier) } deletionAnimator.startAnimation() + + // If all media is deleted, close the viewer + if identifiersAfterDeletion.isEmpty { + navigationController?.popViewController(animated: true) + return + } + await deletionAnimator.addCompletion() - // TODO: Handle empty // Reload data source let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { self.pageControlBar.deleteItems([identifier], animated: true) @@ -749,6 +755,17 @@ extension MediaViewerViewController: UINavigationControllerDelegate { willBeginPopTransitionTo: toVC ) } + + if operation == .pop, + mediaViewerDataSource.mediaIdentifiers(for: self).isEmpty { + // When all media is deleted + return MediaViewerTransition( + operation: operation, + sourceView: nil, + sourceImage: { nil } + ) + } + let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource.mediaViewer( self, transitionSourceViewForMediaWith: currentMediaIdentifier From a5edb675065ba6fb6179c2b75f5e901766f72b7d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 01:17:48 +0900 Subject: [PATCH 055/141] fix: ignore pan when the page control bar is not expanded --- Sources/MediaViewer/MediaViewerViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 913e4acf..350063b1 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -571,6 +571,10 @@ open class MediaViewerViewController: UIPageViewController { @objc private func panned(recognizer: UIPanGestureRecognizer) { + guard pageControlBar.state == .expanded else { + recognizer.state = .failed + return + } if recognizer.state == .began { // Start the interactive pop transition let sourceView = mediaViewerDataSource.mediaViewer( From e19fc16b143bec7b52d360d8e360895c364bc56d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 01:26:17 +0900 Subject: [PATCH 056/141] fix: ignore tap on the page control bar during the deletion --- .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index b8fb1cc0..f02bc7cb 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -488,6 +488,8 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) + guard state != .deleting else { return } + if case .normal(let barLayout) = layout, barLayout.style.indexPathForExpandingItem != indexPath { expandAndScrollToItem( From e5d9fb03860f37ba5bd8c862a100ae9a5f8630b4 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 9 Nov 2023 21:12:55 +0900 Subject: [PATCH 057/141] update: layout after deletion --- .../MediaViewerViewController.swift | 22 +++++++++++++++---- .../MediaViewerPageControlBar.swift | 19 +++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 350063b1..217e12bc 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -457,9 +457,22 @@ open class MediaViewerViewController: UIPageViewController { defer { pageControlBar.finishDeletion() } let identifier = AnyMediaIdentifier(rawValue: identifier) + let currentMediaIdentifier = currentMediaIdentifier + let isDeletingLastPage = identifier == mediaViewerVM.mediaIdentifiers.last let isDeletingCurrentPage = identifier == currentMediaIdentifier + let destinationIdentifier: AnyMediaIdentifier + if isDeletingCurrentPage { + destinationIdentifier = isDeletingLastPage + // FIXME: force unwrap + ? self.mediaViewerVM.mediaIdentifier(before: identifier)! // Stay on the last page + : self.mediaViewerVM.mediaIdentifier(after: identifier)! + } else { + // Stay on the current page + destinationIdentifier = currentMediaIdentifier + } + try await deleteAction() let identifiersAfterDeletion = mediaViewerDataSource!.mediaIdentifiers(for: self) @@ -488,13 +501,14 @@ open class MediaViewerViewController: UIPageViewController { // Reload data source let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { - self.pageControlBar.deleteItems([identifier], animated: true) + self.pageControlBar.deleteItems( + [identifier], + destinationIdentifier: destinationIdentifier, + animated: true + ) // Move page if deleted an image on the current page if isDeletingCurrentPage { - let destinationIdentifier = isDeletingLastPage - ? self.mediaViewerVM.mediaIdentifier(before: identifier)! - : self.mediaViewerVM.mediaIdentifier(after: identifier)! self.move(toMediaWith: destinationIdentifier, animated: true) } } diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index f02bc7cb..66487c57 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -399,13 +399,30 @@ extension MediaViewerPageControlBar { /// /// - Parameters: /// - identifiers: Identifiers for media to delete. + /// - destinationIdentifier: An identifier for media to move to after deletion. /// - animated: Whether to animate the deletion. - func deleteItems(_ identifiers: [AnyMediaIdentifier], animated: Bool) { + func deleteItems( + _ identifiers: [AnyMediaIdentifier], + destinationIdentifier: AnyMediaIdentifier, + animated: Bool + ) { assert(state == .deleting) var snapshot = diffableDataSource.snapshot() snapshot.deleteItems(identifiers) diffableDataSource.apply(snapshot, animatingDifferences: animated) + + guard let indexPath = diffableDataSource.indexPath(for: destinationIdentifier) else { + return + } + updateLayout( + expandingItemAt: indexPath, + expandingThumbnailWidthToHeight: dataSource?.mediaViewerPageControlBar( + self, + widthToHeightOfThumbnailWith: destinationIdentifier + ), + animated: animated + ) } } From bc0f039db1a3c060688fd026ba431877623471ff Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 02:36:27 +0900 Subject: [PATCH 058/141] add: MediaViewerVC.move(toMediaWith:direction:animated:completion:) --- .../MediaViewerViewController.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 217e12bc..28f159d9 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -408,9 +408,8 @@ open class MediaViewerViewController: UIPageViewController { animated: Bool, completion: ((Bool) -> Void)? = nil ) { - guard let mediaViewerPage = makeMediaViewerPage(with: identifier) else { return } - setViewControllers( - [mediaViewerPage], + move( + toMediaWith: identifier, direction: mediaViewerVM.moveDirection( from: currentMediaIdentifier, to: identifier @@ -420,6 +419,21 @@ open class MediaViewerViewController: UIPageViewController { ) } + private func move( + toMediaWith identifier: AnyMediaIdentifier, + direction: NavigationDirection, + animated: Bool, + completion: ((Bool) -> Void)? = nil + ) { + guard let mediaViewerPage = makeMediaViewerPage(with: identifier) else { return } + setViewControllers( + [mediaViewerPage], + direction: direction, + animated: animated, + completion: completion + ) + } + /// An error on media deletion. public enum DeletionError: Error { From 8b59f5cab0da84eaefe29463b33083fcd7f1a643 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 02:40:31 +0900 Subject: [PATCH 059/141] fix: deletion --- .../MediaViewerViewController.swift | 68 ++++++++++++------- .../MediaViewer/MediaViewerViewModel.swift | 40 +++++++++++ 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 28f159d9..a3603cf5 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -467,38 +467,42 @@ open class MediaViewerViewController: UIPageViewController { with identifier: MediaIdentifier, after deleteAction: () async throws -> Void ) async throws where MediaIdentifier: Hashable { + guard let mediaViewerDataSource else { + preconditionFailure("The media viewer requires the data source.") + } + try pageControlBar.beginDeletion() defer { pageControlBar.finishDeletion() } let identifier = AnyMediaIdentifier(rawValue: identifier) - let currentMediaIdentifier = currentMediaIdentifier - - let isDeletingLastPage = identifier == mediaViewerVM.mediaIdentifiers.last - let isDeletingCurrentPage = identifier == currentMediaIdentifier - - let destinationIdentifier: AnyMediaIdentifier - if isDeletingCurrentPage { - destinationIdentifier = isDeletingLastPage - // FIXME: force unwrap - ? self.mediaViewerVM.mediaIdentifier(before: identifier)! // Stay on the last page - : self.mediaViewerVM.mediaIdentifier(after: identifier)! - } else { - // Stay on the current page - destinationIdentifier = currentMediaIdentifier - } + let currentPageVC = currentPageViewController - try await deleteAction() + let animationAfterDeletion = mediaViewerVM.pagingAnimationAfterDeletion( + deletingIdentifier: identifier, + currentIdentifier: currentPageVC.mediaIdentifier + ) + + // MARK: Delete media - let identifiersAfterDeletion = mediaViewerDataSource!.mediaIdentifiers(for: self) + let identifiers = mediaViewerDataSource.mediaIdentifiers(for: self) precondition( - identifiersAfterDeletion.count == mediaViewerVM.mediaIdentifiers.count - 1, + mediaViewerVM.mediaIdentifiers.count == identifiers.count + ) + + try await deleteAction() + mediaViewerVM.deleteMediaIdentifier(identifier) + + let identifiersAfterDeletion = mediaViewerDataSource.mediaIdentifiers(for: self) + assert( + mediaViewerVM.mediaIdentifiers.count == identifiersAfterDeletion.count, "You have to complete deletion in `deleteAction` closure." ) - // Perform delete animation + // MARK: Perform delete animation + let deletionAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { - if isDeletingCurrentPage { - let currentPageView = self.currentPageViewController.mediaViewerOnePageView + if identifier == currentPageVC.mediaIdentifier { + let currentPageView = currentPageVC.mediaViewerOnePageView currentPageView.performDeleteAnimationBody() } self.pageControlBar.performDeleteAnimationBody(for: identifier) @@ -506,24 +510,36 @@ open class MediaViewerViewController: UIPageViewController { deletionAnimator.startAnimation() // If all media is deleted, close the viewer - if identifiersAfterDeletion.isEmpty { + guard let animationAfterDeletion else { + assert(identifiersAfterDeletion.isEmpty) navigationController?.popViewController(animated: true) return } await deletionAnimator.addCompletion() - // Reload data source + // MARK: Finalize deletion + let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { self.pageControlBar.deleteItems( [identifier], - destinationIdentifier: destinationIdentifier, + destinationIdentifier: animationAfterDeletion.destinationIdentifier, animated: true ) // Move page if deleted an image on the current page - if isDeletingCurrentPage { - self.move(toMediaWith: destinationIdentifier, animated: true) + if let direction = animationAfterDeletion.direction { + /* + * NOTE: + * move(toPage:animated:) does not work here. + * That method uses currentPage, which may crash as it tries + * to reference a deleted page. + */ + self.move( + toMediaWith: animationAfterDeletion.destinationIdentifier, + direction: direction, + animated: true + ) } } finishAnimator.startAnimation() diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 0e72af31..92891089 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -53,3 +53,43 @@ final class MediaViewerViewModel: ObservableObject { return destinationPage < currentPage ? .reverse : .forward } } + +// MARK: - Deletion - + +extension MediaViewerViewModel { + + func deleteMediaIdentifier(_ identifier: AnyMediaIdentifier) { + guard let page = page(with: identifier) else { return } + mediaIdentifiers.remove(at: page) + } + + func pagingAnimationAfterDeletion( + deletingIdentifier: AnyMediaIdentifier, + currentIdentifier: AnyMediaIdentifier + ) -> ( + destinationIdentifier: AnyMediaIdentifier, + direction: UIPageViewController.NavigationDirection? + )? { + guard deletingIdentifier == currentIdentifier else { + // Stay on the current page + return ( + destinationIdentifier: currentIdentifier, + direction: nil + ) + } + + let isDeletingLastPage = deletingIdentifier == mediaIdentifiers.last + if isDeletingLastPage { + guard let previousIdentifier = mediaIdentifier(before: deletingIdentifier) else { + // When all pages are deleted, close the viewer and do not perform paging animation + return nil + } + // Move back to the new last page + return (destinationIdentifier: previousIdentifier, .reverse) + } else { + // Move to the next page + guard let nextIdentifier = mediaIdentifier(after: deletingIdentifier) else { return nil } + return (destinationIdentifier: nextIdentifier, .forward) + } + } +} From ec6332fdcf88179747741c7e029fe80233fc1d8b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 03:00:01 +0900 Subject: [PATCH 060/141] chore: add test plan --- .../MediaViewerDemo.xcodeproj/project.pbxproj | 2 ++ .../xcschemes/MediaViewerDemo.xcscheme | 9 ++++-- .../MediaViewerDemo.xctestplan | 29 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 Demo/MediaViewerDemo/MediaViewerDemo.xctestplan diff --git a/Demo/MediaViewerDemo.xcodeproj/project.pbxproj b/Demo/MediaViewerDemo.xcodeproj/project.pbxproj index 13561e99..d6a3503c 100644 --- a/Demo/MediaViewerDemo.xcodeproj/project.pbxproj +++ b/Demo/MediaViewerDemo.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 5A13B05F2AF61D530095729F /* CameraLikeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraLikeViewController.swift; sourceTree = ""; }; 5A13B0612AF62BFA0095729F /* PHImageFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHImageFetcher.swift; sourceTree = ""; }; 5A13B0652AF660710095729F /* CameraLikeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraLikeView.swift; sourceTree = ""; }; + 5A13EC8B2AFD554C0007E405 /* MediaViewerDemo.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MediaViewerDemo.xctestplan; sourceTree = ""; }; 5A54612629A2594700F16456 /* MediaViewerDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MediaViewerDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5A54612929A2594700F16456 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5A54612B29A2594700F16456 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -114,6 +115,7 @@ 5A54613229A2594800F16456 /* Assets.xcassets */, 5A54613429A2594800F16456 /* LaunchScreen.storyboard */, 5A54613729A2594800F16456 /* Info.plist */, + 5A13EC8B2AFD554C0007E405 /* MediaViewerDemo.xctestplan */, ); path = MediaViewerDemo; sourceTree = ""; diff --git a/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme b/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme index d3bb377f..f79ca95e 100644 --- a/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme +++ b/Demo/MediaViewerDemo.xcodeproj/xcshareddata/xcschemes/MediaViewerDemo.xcscheme @@ -26,8 +26,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Date: Fri, 10 Nov 2023 02:58:03 +0900 Subject: [PATCH 061/141] add: MediaViewerViewModelTests --- .../MediaViewerViewModelTests.swift | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 Tests/MediaViewerTests/MediaViewerViewModelTests.swift diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift new file mode 100644 index 00000000..10a9160e --- /dev/null +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -0,0 +1,97 @@ +// +// MediaViewerViewModelTests.swift +// +// +// Created by Yusaku Nishi on 2023/11/10. +// + +import XCTest +@testable import MediaViewer + +final class MediaViewerViewModelTests: XCTestCase { + + private var mediaViewerVM: MediaViewerViewModel! + + override func setUp() { + mediaViewerVM = .init() + } + + func testPagingAnimationAfterDeletion() throws { + // Arrange + let identifiers = (0..<5).map(AnyMediaIdentifier.init) + + try XCTContext.runActivity( + named: "When the current page is deleted" + ) { _ in + try XCTContext.runActivity( + named: "When the non-last page is deleted, forward animation is performed" + ) { _ in + // Arrange + mediaViewerVM.mediaIdentifiers = identifiers + + // Act + let animation = mediaViewerVM.pagingAnimationAfterDeletion( + deletingIdentifier: identifiers[3], + currentIdentifier: identifiers[3] + ) + + // Assert + let (destination, direction) = try XCTUnwrap(animation) + XCTAssertEqual(destination, identifiers[4]) // Next page + XCTAssertEqual(direction, .forward) + } + + try XCTContext.runActivity( + named: "When the last page is deleted, reverse animation is performed" + ) { _ in + // Arrange + mediaViewerVM.mediaIdentifiers = identifiers + + // Act + let animation = mediaViewerVM.pagingAnimationAfterDeletion( + deletingIdentifier: identifiers.last!, + currentIdentifier: identifiers.last! + ) + + // Assert + let (destination, direction) = try XCTUnwrap(animation) + XCTAssertEqual(destination, identifiers[3]) // Previous page + XCTAssertEqual(direction, .reverse) + } + + XCTContext.runActivity( + named: "When all pages are deleted, no animation is performed" + ) { _ in + // Arrange + mediaViewerVM.mediaIdentifiers = [identifiers[0]] + + // Act + let animation = mediaViewerVM.pagingAnimationAfterDeletion( + deletingIdentifier: identifiers[0], + currentIdentifier: identifiers[0] + ) + + // Assert + XCTAssertNil(animation) + } + } + + try XCTContext.runActivity( + named: "When a non-current page is deleted, the page is kept" + ) { _ in + // Arrange + mediaViewerVM.mediaIdentifiers = identifiers + + // Act + let animation = mediaViewerVM.pagingAnimationAfterDeletion( + deletingIdentifier: identifiers[1], + currentIdentifier: identifiers[3] + ) + + // Assert + let (destination, direction) = try XCTUnwrap(animation) + XCTAssertEqual(destination, identifiers[3]) + XCTAssertNil(direction) + } + } +} From 4e300a7f31b1d9b7f1358a48c7f04b8e6c4f7ac3 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 03:02:47 +0900 Subject: [PATCH 062/141] chore: gather coverage --- Demo/MediaViewerDemo/MediaViewerDemo.xctestplan | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Demo/MediaViewerDemo/MediaViewerDemo.xctestplan b/Demo/MediaViewerDemo/MediaViewerDemo.xctestplan index cfcb6954..e7fa157e 100644 --- a/Demo/MediaViewerDemo/MediaViewerDemo.xctestplan +++ b/Demo/MediaViewerDemo/MediaViewerDemo.xctestplan @@ -9,7 +9,15 @@ } ], "defaultOptions" : { - "codeCoverage" : false, + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:..", + "identifier" : "MediaViewer", + "name" : "MediaViewer" + } + ] + }, "targetForVariableExpansion" : { "containerPath" : "container:MediaViewerDemo.xcodeproj", "identifier" : "5A54612529A2594700F16456", From ed7363d9a1a1e494eea46dee174042bd03083c3b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 03:03:46 +0900 Subject: [PATCH 063/141] delete: test template --- Tests/MediaViewerTests/MediaViewerTests.swift | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 Tests/MediaViewerTests/MediaViewerTests.swift diff --git a/Tests/MediaViewerTests/MediaViewerTests.swift b/Tests/MediaViewerTests/MediaViewerTests.swift deleted file mode 100644 index bbccab4a..00000000 --- a/Tests/MediaViewerTests/MediaViewerTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest -@testable import MediaViewer - -final class MediaViewerTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - } -} From 0188ea765691f156fd30470296b0e9414acba71b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 21:25:02 +0900 Subject: [PATCH 064/141] chore: improve demo add refresh button --- .../Grid/AsyncImagesViewController.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 53a30b76..0ea1c544 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -47,6 +47,13 @@ final class AsyncImagesViewController: UIViewController { ) } + private lazy var refreshButton = UIBarButtonItem( + systemItem: .refresh, + primaryAction: .init { [weak self] _ in + Task { await self?.refresh(animated: true) } + } + ) + private let toggleContentModeButton = UIBarButtonItem() private var preferredContentMode: UIView.ContentMode = .scaleAspectFill @@ -72,18 +79,17 @@ final class AsyncImagesViewController: UIViewController { // Navigation navigationItem.title = "Async Sample" navigationItem.backButtonDisplayMode = .minimal + navigationItem.leftBarButtonItem = refreshButton + navigationItem.rightBarButtonItem = toggleContentModeButton toggleContentModeButton.primaryAction = UIAction( image: .init(systemName: "rectangle.arrowtriangle.2.inward") ) { [weak self] _ in self?.toggleContentMode() } - navigationItem.rightBarButtonItem = toggleContentModeButton } private func loadPhotos() async { - let assets = await PHImageFetcher.imageAssets() - // Hide the collection view until ready imageGridView.collectionView.isHidden = true defer { @@ -96,10 +102,7 @@ final class AsyncImagesViewController: UIViewController { } } - var snapshot = dataSource.snapshot() - snapshot.appendSections([0]) - snapshot.appendItems(assets) - await dataSource.apply(snapshot, animatingDifferences: false) + let assets = await refresh(animated: false) // Scroll to the bottom if needed if let lastAsset = assets.last { @@ -113,6 +116,17 @@ final class AsyncImagesViewController: UIViewController { // MARK: - Methods + @discardableResult + private func refresh(animated: Bool) async -> [PHAsset] { + let assets = await PHImageFetcher.imageAssets() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(assets) + await dataSource.apply(snapshot, animatingDifferences: animated) + return assets + } + private func toggleContentMode() { let newContentMode: UIView.ContentMode let systemImageName: String From 46d5dc3b97650427b431a74adae1a14bfca7ae16 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 21:30:11 +0900 Subject: [PATCH 065/141] chore: refactor demo --- .../Samples/Grid/AsyncImagesViewController.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 0ea1c544..e3240999 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -54,7 +54,13 @@ final class AsyncImagesViewController: UIViewController { } ) - private let toggleContentModeButton = UIBarButtonItem() + private lazy var toggleContentModeButton = UIBarButtonItem( + primaryAction: .init( + image: .init(systemName: "rectangle.arrowtriangle.2.inward") + ) { [weak self] _ in + self?.toggleContentMode() + } + ) private var preferredContentMode: UIView.ContentMode = .scaleAspectFill @@ -81,12 +87,6 @@ final class AsyncImagesViewController: UIViewController { navigationItem.backButtonDisplayMode = .minimal navigationItem.leftBarButtonItem = refreshButton navigationItem.rightBarButtonItem = toggleContentModeButton - - toggleContentModeButton.primaryAction = UIAction( - image: .init(systemName: "rectangle.arrowtriangle.2.inward") - ) { [weak self] _ in - self?.toggleContentMode() - } } private func loadPhotos() async { From 6cbba77df0481b2cd10402cd461884c228d40584 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 10 Nov 2023 21:48:20 +0900 Subject: [PATCH 066/141] chore: improve demo add refresh control --- .../Samples/Grid/AsyncImagesViewController.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index e3240999..e1ef55fa 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -87,6 +87,14 @@ final class AsyncImagesViewController: UIViewController { navigationItem.backButtonDisplayMode = .minimal navigationItem.leftBarButtonItem = refreshButton navigationItem.rightBarButtonItem = toggleContentModeButton + + // Subviews + imageGridView.collectionView.refreshControl = .init( + frame: .zero, + primaryAction: .init { [weak self] _ in + Task { await self?.refresh(animated: true) } + } + ) } private func loadPhotos() async { @@ -124,6 +132,8 @@ final class AsyncImagesViewController: UIViewController { snapshot.appendSections([0]) snapshot.appendItems(assets) await dataSource.apply(snapshot, animatingDifferences: animated) + + imageGridView.collectionView.refreshControl?.endRefreshing() return assets } From 5c2e51a84a47a528855bd893a6daf4ed9479a57c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 20 Nov 2023 00:17:42 +0900 Subject: [PATCH 067/141] remove: unnecessary explicit unwrapping --- Sources/MediaViewer/MediaViewerViewController.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index a3603cf5..ba08a257 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -467,10 +467,6 @@ open class MediaViewerViewController: UIPageViewController { with identifier: MediaIdentifier, after deleteAction: () async throws -> Void ) async throws where MediaIdentifier: Hashable { - guard let mediaViewerDataSource else { - preconditionFailure("The media viewer requires the data source.") - } - try pageControlBar.beginDeletion() defer { pageControlBar.finishDeletion() } From 9ee574a79d76aba8adb37a6ef358afa93d6e523d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 22 Nov 2023 00:04:39 +0900 Subject: [PATCH 068/141] delete: makeMediaViewerPage(with:dataSource:) --- .../MediaViewerViewController.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index ba08a257..6de563e9 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -156,8 +156,7 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) let mediaViewerPage = makeMediaViewerPage( - with: AnyMediaIdentifier(rawValue: mediaIdentifier), - dataSource: dataSource + with: AnyMediaIdentifier(rawValue: mediaIdentifier) ) setViewControllers([mediaViewerPage], direction: .forward, animated: false) @@ -425,9 +424,8 @@ open class MediaViewerViewController: UIPageViewController { animated: Bool, completion: ((Bool) -> Void)? = nil ) { - guard let mediaViewerPage = makeMediaViewerPage(with: identifier) else { return } setViewControllers( - [mediaViewerPage], + [makeMediaViewerPage(with: identifier)], direction: direction, animated: animated, completion: completion @@ -711,15 +709,14 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { } private func makeMediaViewerPage( - with identifier: AnyMediaIdentifier, - dataSource: some MediaViewerDataSource + with identifier: AnyMediaIdentifier ) -> MediaViewerOnePageViewController { let mediaViewerPage = MediaViewerOnePageViewController( mediaIdentifier: identifier ) mediaViewerPage.delegate = self - let media = dataSource.mediaViewer(self, mediaWith: identifier) + let media = mediaViewerDataSource.mediaViewer(self, mediaWith: identifier) switch media { case .image(.sync(let image)): mediaViewerPage.mediaViewerOnePageView.setImage(image, with: .none) @@ -731,13 +728,6 @@ extension MediaViewerViewController: UIPageViewControllerDataSource { } return mediaViewerPage } - - private func makeMediaViewerPage( - with identifier: AnyMediaIdentifier - ) -> MediaViewerOnePageViewController? { - guard let mediaViewerDataSource else { return nil } - return makeMediaViewerPage(with: identifier, dataSource: mediaViewerDataSource) - } } // MARK: - MediaViewerPageControlBarDataSource - From b4558a63805d1bec54788d365ee031d9406f01f2 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 22:27:53 +0900 Subject: [PATCH 069/141] add: CollectionDifference.changes --- .../CollectionDifference+Extension.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift diff --git a/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift new file mode 100644 index 00000000..a43a0eb8 --- /dev/null +++ b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift @@ -0,0 +1,32 @@ +// +// CollectionDifference+Extension.swift +// +// +// Created by Yusaku Nishi on 2023/11/21. +// + +extension CollectionDifference { + + typealias ChangeAssociatedValues = ( + offset: Int, + element: ChangeElement, + associatedWith: Int? + ) + + var changes: ( + insertions: [ChangeAssociatedValues], + removals: [ChangeAssociatedValues] + ) { + var insertions: [ChangeAssociatedValues] = [] + var removals: [ChangeAssociatedValues] = [] + for change in self { + switch change { + case .insert(let offset, let element, let associatedWith): + insertions.append((offset, element, associatedWith)) + case .remove(let offset, let element, let associatedWith): + removals.append((offset, element, associatedWith)) + } + } + return (insertions: insertions, removals: removals) + } +} From 249489ade89e32ca13718be95be014bdac79b086 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 22:29:25 +0900 Subject: [PATCH 070/141] add: AnyMediaIdentifier.init(rawValue:) --- Sources/MediaViewer/MediaViewerViewController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 6de563e9..aee31f9c 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -11,6 +11,12 @@ import Combine /// A type-erased media identifier. struct AnyMediaIdentifier: Hashable { let rawValue: AnyHashable + + init( + rawValue: MediaIdentifier + ) where MediaIdentifier: Hashable { + self.rawValue = rawValue + } } /// An media viewer. From a486f1cd47153d27e1a49c0bf0a962900cd59b54 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 22:33:10 +0900 Subject: [PATCH 071/141] add: MediaViewer.reloadMedia() --- .../MediaViewerViewController.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index aee31f9c..8ec1e78e 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -447,6 +447,30 @@ open class MediaViewerViewController: UIPageViewController { case notReadyToDelete } + open func reloadMedia() async { + let newIdentifiers = mediaViewerDataSource + .mediaIdentifiers(for: self) + .map { AnyMediaIdentifier(rawValue: $0) } + + let (insertions, removals) = newIdentifiers.difference( + from: mediaViewerVM.mediaIdentifiers + ).changes + + mediaViewerVM.mediaIdentifiers = newIdentifiers + + // TODO: Run animations at the same time + await insertMedia(with: insertions.map(\.element)) + await deleteMedia(with: removals.map(\.element)) + } + + private func insertMedia(with identifiers: [AnyMediaIdentifier]) async { + fatalError("Not implemented.") // TODO: implement + } + + private func deleteMedia(with identifiers: [AnyMediaIdentifier]) async { + fatalError("Not implemented.") // TODO: implement + } + /// Deletes media with the specified identifier. /// /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. If all media is deleted, the viewer will close. From adce0f23d10d97855e1bd25f617b440d207f90aa Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 22:43:08 +0900 Subject: [PATCH 072/141] change: MediaViewerPageControlBar.performDeleteAnimationBody(for:) argument type --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- .../PageControlBar/MediaViewerPageControlBar.swift | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 8ec1e78e..76798339 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -529,7 +529,7 @@ open class MediaViewerViewController: UIPageViewController { let currentPageView = currentPageVC.mediaViewerOnePageView currentPageView.performDeleteAnimationBody() } - self.pageControlBar.performDeleteAnimationBody(for: identifier) + self.pageControlBar.performDeleteAnimationBody(for: [identifier]) } deletionAnimator.startAnimation() diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 66487c57..12b2f836 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -386,11 +386,13 @@ extension MediaViewerPageControlBar { /// It also does not update the data source so you have to call /// `deleteItems(_:animated:)` after this animation is finished. /// - /// - Parameter identifier: An identifier for media to perform delete animation. - func performDeleteAnimationBody(for identifier: AnyMediaIdentifier) { + /// - Parameter identifiers: Identifiers for media to perform delete animation. + func performDeleteAnimationBody(for identifiers: [AnyMediaIdentifier]) { assert(state == .deleting) - cell(for: identifier)?.performDeleteAnimationBody() + for identifier in identifiers { + cell(for: identifier)?.performDeleteAnimationBody() + } } /// Deletes specified items. From 6346a8fc3e4d3ae3395b832b75b60b57b57fcad6 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 22:48:37 +0900 Subject: [PATCH 073/141] add: destinationPageVCAfterDeletion --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 76798339..e8dc72f1 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -69,7 +69,12 @@ open class MediaViewerViewController: UIPageViewController { currentPageViewController.mediaIdentifier } + private var destinationPageVCAfterDeletion: MediaViewerOnePageViewController? + var currentPageViewController: MediaViewerOnePageViewController { + if let destinationPageVCAfterDeletion { + return destinationPageVCAfterDeletion + } guard let mediaViewerOnePage = viewControllers?.first as? MediaViewerOnePageViewController else { preconditionFailure( "\(Self.self) must have only one \(MediaViewerOnePageViewController.self)." From d30e9f6022b0127398738870d7aa16956b6f770c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 23:16:26 +0900 Subject: [PATCH 074/141] add: MediaViewerVM.pagingAnimation(afterDeleting:currentIdentifier:) --- .../MediaViewer/MediaViewerViewModel.swift | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 92891089..5b03dae5 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -70,7 +70,20 @@ extension MediaViewerViewModel { destinationIdentifier: AnyMediaIdentifier, direction: UIPageViewController.NavigationDirection? )? { - guard deletingIdentifier == currentIdentifier else { + pagingAnimation( + afterDeleting: [deletingIdentifier], + currentIdentifier: currentIdentifier + ) + } + + func pagingAnimation( + afterDeleting deletingIdentifiers: [AnyMediaIdentifier], + currentIdentifier: AnyMediaIdentifier + ) -> ( + destinationIdentifier: AnyMediaIdentifier, + direction: UIPageViewController.NavigationDirection? + )? { + guard deletingIdentifiers.contains(currentIdentifier) else { // Stay on the current page return ( destinationIdentifier: currentIdentifier, @@ -78,18 +91,25 @@ extension MediaViewerViewModel { ) } - let isDeletingLastPage = deletingIdentifier == mediaIdentifiers.last - if isDeletingLastPage { - guard let previousIdentifier = mediaIdentifier(before: deletingIdentifier) else { - // When all pages are deleted, close the viewer and do not perform paging animation - return nil - } - // Move back to the new last page - return (destinationIdentifier: previousIdentifier, .reverse) - } else { - // Move to the next page - guard let nextIdentifier = mediaIdentifier(after: deletingIdentifier) else { return nil } - return (destinationIdentifier: nextIdentifier, .forward) + let splitIdentifiers = mediaIdentifiers.split( + separator: currentIdentifier, + maxSplits: 2, + omittingEmptySubsequences: false + ) + let backwardIdentifiers = splitIdentifiers[0] + let forwardIdentifiers = splitIdentifiers[1] + + if let nearestForward = forwardIdentifiers.first(where: { + !deletingIdentifiers.contains($0) + }) { + return (destinationIdentifier: nearestForward, .forward) + } else if let nearestBackward = backwardIdentifiers.last(where: { + !deletingIdentifiers.contains($0) + }) { + return (destinationIdentifier: nearestBackward, .reverse) } + + // When all pages are deleted, close the viewer and do not perform paging animation + return nil } } From 55bbb94286441c571da883c059ddff145fe8dd8c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 23:18:02 +0900 Subject: [PATCH 075/141] delete: MediaViewerVM.pagingAnimationAfterDeletion(...) --- .../MediaViewerViewController.swift | 4 ++-- Sources/MediaViewer/MediaViewerViewModel.swift | 13 ------------- .../MediaViewerViewModelTests.swift | 18 +++++++++--------- 3 files changed, 11 insertions(+), 24 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index e8dc72f1..3e9ed130 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -506,8 +506,8 @@ open class MediaViewerViewController: UIPageViewController { let identifier = AnyMediaIdentifier(rawValue: identifier) let currentPageVC = currentPageViewController - let animationAfterDeletion = mediaViewerVM.pagingAnimationAfterDeletion( - deletingIdentifier: identifier, + let animationAfterDeletion = mediaViewerVM.pagingAnimation( + afterDeleting: [identifier], currentIdentifier: currentPageVC.mediaIdentifier ) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 5b03dae5..dc4787ae 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -63,19 +63,6 @@ extension MediaViewerViewModel { mediaIdentifiers.remove(at: page) } - func pagingAnimationAfterDeletion( - deletingIdentifier: AnyMediaIdentifier, - currentIdentifier: AnyMediaIdentifier - ) -> ( - destinationIdentifier: AnyMediaIdentifier, - direction: UIPageViewController.NavigationDirection? - )? { - pagingAnimation( - afterDeleting: [deletingIdentifier], - currentIdentifier: currentIdentifier - ) - } - func pagingAnimation( afterDeleting deletingIdentifiers: [AnyMediaIdentifier], currentIdentifier: AnyMediaIdentifier diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index 10a9160e..0e76a326 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -16,7 +16,7 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM = .init() } - func testPagingAnimationAfterDeletion() throws { + func testPagingAnimation() throws { // Arrange let identifiers = (0..<5).map(AnyMediaIdentifier.init) @@ -30,8 +30,8 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM.mediaIdentifiers = identifiers // Act - let animation = mediaViewerVM.pagingAnimationAfterDeletion( - deletingIdentifier: identifiers[3], + let animation = mediaViewerVM.pagingAnimation( + afterDeleting: [identifiers[3]], currentIdentifier: identifiers[3] ) @@ -48,8 +48,8 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM.mediaIdentifiers = identifiers // Act - let animation = mediaViewerVM.pagingAnimationAfterDeletion( - deletingIdentifier: identifiers.last!, + let animation = mediaViewerVM.pagingAnimation( + afterDeleting: [identifiers.last!], currentIdentifier: identifiers.last! ) @@ -66,8 +66,8 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM.mediaIdentifiers = [identifiers[0]] // Act - let animation = mediaViewerVM.pagingAnimationAfterDeletion( - deletingIdentifier: identifiers[0], + let animation = mediaViewerVM.pagingAnimation( + afterDeleting: [identifiers[0]], currentIdentifier: identifiers[0] ) @@ -83,8 +83,8 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM.mediaIdentifiers = identifiers // Act - let animation = mediaViewerVM.pagingAnimationAfterDeletion( - deletingIdentifier: identifiers[1], + let animation = mediaViewerVM.pagingAnimation( + afterDeleting: [identifiers[1]], currentIdentifier: identifiers[3] ) From c9154953b9c59a6bb9f5f456b0637894a9ec71c3 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 21 Nov 2023 23:55:49 +0900 Subject: [PATCH 076/141] chore: improve test --- .../MediaViewerViewModelTests.swift | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index 0e76a326..123707f1 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -19,56 +19,48 @@ final class MediaViewerViewModelTests: XCTestCase { func testPagingAnimation() throws { // Arrange let identifiers = (0..<5).map(AnyMediaIdentifier.init) + mediaViewerVM.mediaIdentifiers = identifiers try XCTContext.runActivity( named: "When the current page is deleted" ) { _ in try XCTContext.runActivity( - named: "When the non-last page is deleted, forward animation is performed" + named: "When the forward page still exists, the viewer should move to the nearest forward page" ) { _ in - // Arrange - mediaViewerVM.mediaIdentifiers = identifiers - // Act let animation = mediaViewerVM.pagingAnimation( - afterDeleting: [identifiers[3]], - currentIdentifier: identifiers[3] + afterDeleting: Array(identifiers[2...3]), + currentIdentifier: identifiers[2] ) // Assert let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[4]) // Next page + XCTAssertEqual(destination, identifiers[4]) // Nearest forward page XCTAssertEqual(direction, .forward) } try XCTContext.runActivity( - named: "When the last page is deleted, reverse animation is performed" + named: "When all forward pages are deleted, the viewer should move back to the new last page" ) { _ in - // Arrange - mediaViewerVM.mediaIdentifiers = identifiers - // Act let animation = mediaViewerVM.pagingAnimation( - afterDeleting: [identifiers.last!], - currentIdentifier: identifiers.last! + afterDeleting: Array(identifiers[2...]), + currentIdentifier: identifiers[2] ) // Assert let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[3]) // Previous page + XCTAssertEqual(destination, identifiers[1]) // New last page XCTAssertEqual(direction, .reverse) } XCTContext.runActivity( named: "When all pages are deleted, no animation is performed" ) { _ in - // Arrange - mediaViewerVM.mediaIdentifiers = [identifiers[0]] - // Act let animation = mediaViewerVM.pagingAnimation( - afterDeleting: [identifiers[0]], - currentIdentifier: identifiers[0] + afterDeleting: identifiers, + currentIdentifier: identifiers[2] ) // Assert @@ -77,20 +69,17 @@ final class MediaViewerViewModelTests: XCTestCase { } try XCTContext.runActivity( - named: "When a non-current page is deleted, the page is kept" + named: "When a non-current page is deleted, the viewer should stay on the current page" ) { _ in - // Arrange - mediaViewerVM.mediaIdentifiers = identifiers - // Act let animation = mediaViewerVM.pagingAnimation( - afterDeleting: [identifiers[1]], - currentIdentifier: identifiers[3] + afterDeleting: [identifiers[1], identifiers[4]], + currentIdentifier: identifiers[2] ) // Assert let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[3]) + XCTAssertEqual(destination, identifiers[2]) XCTAssertNil(direction) } } From 34085b321117ead15bb49e36d547dc3a21abc713 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 22 Nov 2023 00:13:46 +0900 Subject: [PATCH 077/141] add: PagingAnimationAfterDeletion --- .../MediaViewer/MediaViewerViewModel.swift | 22 +++++++--- .../MediaViewerViewModelTests.swift | 43 +++++++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index dc4787ae..bed643ed 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -63,16 +63,18 @@ extension MediaViewerViewModel { mediaIdentifiers.remove(at: page) } + struct PagingAnimationAfterDeletion: Hashable { + let destinationIdentifier: AnyMediaIdentifier + let direction: UIPageViewController.NavigationDirection? + } + func pagingAnimation( afterDeleting deletingIdentifiers: [AnyMediaIdentifier], currentIdentifier: AnyMediaIdentifier - ) -> ( - destinationIdentifier: AnyMediaIdentifier, - direction: UIPageViewController.NavigationDirection? - )? { + ) -> PagingAnimationAfterDeletion? { guard deletingIdentifiers.contains(currentIdentifier) else { // Stay on the current page - return ( + return .init( destinationIdentifier: currentIdentifier, direction: nil ) @@ -89,11 +91,17 @@ extension MediaViewerViewModel { if let nearestForward = forwardIdentifiers.first(where: { !deletingIdentifiers.contains($0) }) { - return (destinationIdentifier: nearestForward, .forward) + return .init( + destinationIdentifier: nearestForward, + direction: .forward + ) } else if let nearestBackward = backwardIdentifiers.last(where: { !deletingIdentifiers.contains($0) }) { - return (destinationIdentifier: nearestBackward, .reverse) + return .init( + destinationIdentifier: nearestBackward, + direction: .reverse + ) } // When all pages are deleted, close the viewer and do not perform paging animation diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index 123707f1..d1c417f4 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -16,15 +16,15 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM = .init() } - func testPagingAnimation() throws { + func testPagingAnimation() { // Arrange let identifiers = (0..<5).map(AnyMediaIdentifier.init) mediaViewerVM.mediaIdentifiers = identifiers - try XCTContext.runActivity( + XCTContext.runActivity( named: "When the current page is deleted" ) { _ in - try XCTContext.runActivity( + XCTContext.runActivity( named: "When the forward page still exists, the viewer should move to the nearest forward page" ) { _ in // Act @@ -34,12 +34,17 @@ final class MediaViewerViewModelTests: XCTestCase { ) // Assert - let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[4]) // Nearest forward page - XCTAssertEqual(direction, .forward) + XCTAssertEqual( + animation, + .init( + // Nearest forward page + destinationIdentifier: identifiers[4], + direction: .forward + ) + ) } - try XCTContext.runActivity( + XCTContext.runActivity( named: "When all forward pages are deleted, the viewer should move back to the new last page" ) { _ in // Act @@ -49,9 +54,14 @@ final class MediaViewerViewModelTests: XCTestCase { ) // Assert - let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[1]) // New last page - XCTAssertEqual(direction, .reverse) + XCTAssertEqual( + animation, + .init( + // New last page + destinationIdentifier: identifiers[1], + direction: .reverse + ) + ) } XCTContext.runActivity( @@ -68,7 +78,7 @@ final class MediaViewerViewModelTests: XCTestCase { } } - try XCTContext.runActivity( + XCTContext.runActivity( named: "When a non-current page is deleted, the viewer should stay on the current page" ) { _ in // Act @@ -78,9 +88,14 @@ final class MediaViewerViewModelTests: XCTestCase { ) // Assert - let (destination, direction) = try XCTUnwrap(animation) - XCTAssertEqual(destination, identifiers[2]) - XCTAssertNil(direction) + XCTAssertEqual( + animation, + .init( + // Current page + destinationIdentifier: identifiers[2], + direction: nil + ) + ) } } } From 8ed345f2f86bbf0f085b423f3095573024738500 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 22 Nov 2023 00:21:33 +0900 Subject: [PATCH 078/141] rename: pagingAnimation => paging --- .../MediaViewerViewController.swift | 10 +++++----- .../MediaViewer/MediaViewerViewModel.swift | 6 +++--- .../MediaViewerViewModelTests.swift | 20 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 3e9ed130..08a588df 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -506,7 +506,7 @@ open class MediaViewerViewController: UIPageViewController { let identifier = AnyMediaIdentifier(rawValue: identifier) let currentPageVC = currentPageViewController - let animationAfterDeletion = mediaViewerVM.pagingAnimation( + let pagingAfterDeletion = mediaViewerVM.paging( afterDeleting: [identifier], currentIdentifier: currentPageVC.mediaIdentifier ) @@ -539,7 +539,7 @@ open class MediaViewerViewController: UIPageViewController { deletionAnimator.startAnimation() // If all media is deleted, close the viewer - guard let animationAfterDeletion else { + guard let pagingAfterDeletion else { assert(identifiersAfterDeletion.isEmpty) navigationController?.popViewController(animated: true) return @@ -552,12 +552,12 @@ open class MediaViewerViewController: UIPageViewController { let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { self.pageControlBar.deleteItems( [identifier], - destinationIdentifier: animationAfterDeletion.destinationIdentifier, + destinationIdentifier: pagingAfterDeletion.destinationIdentifier, animated: true ) // Move page if deleted an image on the current page - if let direction = animationAfterDeletion.direction { + if let direction = pagingAfterDeletion.direction { /* * NOTE: * move(toPage:animated:) does not work here. @@ -565,7 +565,7 @@ open class MediaViewerViewController: UIPageViewController { * to reference a deleted page. */ self.move( - toMediaWith: animationAfterDeletion.destinationIdentifier, + toMediaWith: pagingAfterDeletion.destinationIdentifier, direction: direction, animated: true ) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index bed643ed..4f266a16 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -63,15 +63,15 @@ extension MediaViewerViewModel { mediaIdentifiers.remove(at: page) } - struct PagingAnimationAfterDeletion: Hashable { + struct PagingAfterDeletion: Hashable { let destinationIdentifier: AnyMediaIdentifier let direction: UIPageViewController.NavigationDirection? } - func pagingAnimation( + func paging( afterDeleting deletingIdentifiers: [AnyMediaIdentifier], currentIdentifier: AnyMediaIdentifier - ) -> PagingAnimationAfterDeletion? { + ) -> PagingAfterDeletion? { guard deletingIdentifiers.contains(currentIdentifier) else { // Stay on the current page return .init( diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index d1c417f4..53cbf906 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -16,7 +16,7 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM = .init() } - func testPagingAnimation() { + func testPagingAfterDeletion() { // Arrange let identifiers = (0..<5).map(AnyMediaIdentifier.init) mediaViewerVM.mediaIdentifiers = identifiers @@ -28,14 +28,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When the forward page still exists, the viewer should move to the nearest forward page" ) { _ in // Act - let animation = mediaViewerVM.pagingAnimation( + let pagingAfterDeletion = mediaViewerVM.paging( afterDeleting: Array(identifiers[2...3]), currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - animation, + pagingAfterDeletion, .init( // Nearest forward page destinationIdentifier: identifiers[4], @@ -48,14 +48,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When all forward pages are deleted, the viewer should move back to the new last page" ) { _ in // Act - let animation = mediaViewerVM.pagingAnimation( + let pagingAfterDeletion = mediaViewerVM.paging( afterDeleting: Array(identifiers[2...]), currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - animation, + pagingAfterDeletion, .init( // New last page destinationIdentifier: identifiers[1], @@ -65,16 +65,16 @@ final class MediaViewerViewModelTests: XCTestCase { } XCTContext.runActivity( - named: "When all pages are deleted, no animation is performed" + named: "When all pages are deleted, nothing happens" ) { _ in // Act - let animation = mediaViewerVM.pagingAnimation( + let pagingAfterDeletion = mediaViewerVM.paging( afterDeleting: identifiers, currentIdentifier: identifiers[2] ) // Assert - XCTAssertNil(animation) + XCTAssertNil(pagingAfterDeletion) } } @@ -82,14 +82,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When a non-current page is deleted, the viewer should stay on the current page" ) { _ in // Act - let animation = mediaViewerVM.pagingAnimation( + let pagingAfterDeletion = mediaViewerVM.paging( afterDeleting: [identifiers[1], identifiers[4]], currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - animation, + pagingAfterDeletion, .init( // Current page destinationIdentifier: identifiers[2], From f45167a415b3282e07c254bbcfd1c0f636ccf503 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 22 Nov 2023 22:40:42 +0900 Subject: [PATCH 079/141] change: beginDeletion() throws => beginDeletion() async --- Sources/MediaViewer/MediaViewerViewController.swift | 13 ++----------- .../PageControlBar/MediaViewerPageControlBar.swift | 6 +++--- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 08a588df..4de8e489 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -443,15 +443,6 @@ open class MediaViewerViewController: UIPageViewController { ) } - /// An error on media deletion. - public enum DeletionError: Error { - - /// An error that indicates the media viewer is not ready to delete media. - /// - /// It is thrown when the viewer is unsettled, e.g. during paging or delete animation. - case notReadyToDelete - } - open func reloadMedia() async { let newIdentifiers = mediaViewerDataSource .mediaIdentifiers(for: self) @@ -499,8 +490,8 @@ open class MediaViewerViewController: UIPageViewController { open func deleteMedia( with identifier: MediaIdentifier, after deleteAction: () async throws -> Void - ) async throws where MediaIdentifier: Hashable { - try pageControlBar.beginDeletion() + ) async rethrows where MediaIdentifier: Hashable { + await pageControlBar.beginDeletion() defer { pageControlBar.finishDeletion() } let identifier = AnyMediaIdentifier(rawValue: identifier) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 12b2f836..bde305f7 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -369,9 +369,9 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { - func beginDeletion() throws { - guard state == .expanded else { - throw MediaViewerViewController.DeletionError.notReadyToDelete + func beginDeletion() async { + while state != .expanded { + await Task.yield() } state = .deleting } From 89211112f457a26581f531eb0132a6962b950e55 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 22 Nov 2023 23:09:29 +0900 Subject: [PATCH 080/141] update: implement new deleteMedia(with:...) --- .../MediaViewerViewController.swift | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 4de8e489..4ef4dcfe 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -451,20 +451,90 @@ open class MediaViewerViewController: UIPageViewController { let (insertions, removals) = newIdentifiers.difference( from: mediaViewerVM.mediaIdentifiers ).changes + let deletingIdentifiers = removals.map(\.element) + + let visibleVCBeforeDeletion = currentPageViewController + + // TODO: Consider insertions + let pagingAfterDeletion = mediaViewerVM.paging( + afterDeleting: deletingIdentifiers, + currentIdentifier: visibleVCBeforeDeletion.mediaIdentifier + ) + if let pagingAfterDeletion { + destinationPageVCAfterDeletion = makeMediaViewerPage( + with: pagingAfterDeletion.destinationIdentifier + ) + } mediaViewerVM.mediaIdentifiers = newIdentifiers // TODO: Run animations at the same time await insertMedia(with: insertions.map(\.element)) - await deleteMedia(with: removals.map(\.element)) + await deleteMedia( + with: deletingIdentifiers, + visibleVCBeforeDeletion: visibleVCBeforeDeletion, + pagingAfterDeletion: pagingAfterDeletion + ) + destinationPageVCAfterDeletion = nil } private func insertMedia(with identifiers: [AnyMediaIdentifier]) async { fatalError("Not implemented.") // TODO: implement } - private func deleteMedia(with identifiers: [AnyMediaIdentifier]) async { - fatalError("Not implemented.") // TODO: implement + private func deleteMedia( + with deletedIdentifiers: [AnyMediaIdentifier], + visibleVCBeforeDeletion: MediaViewerOnePageViewController, + pagingAfterDeletion: MediaViewerViewModel.PagingAfterDeletion? + ) async { + await pageControlBar.beginDeletion() + defer { pageControlBar.finishDeletion() } + + let isVisibleMediaDeleted = deletedIdentifiers.contains( + visibleVCBeforeDeletion.mediaIdentifier + ) + let visiblePageView = visibleVCBeforeDeletion.mediaViewerOnePageView + + // MARK: Perform vanish animation + + let vanishAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { + if isVisibleMediaDeleted { + visiblePageView.performDeleteAnimationBody() + } + self.pageControlBar.performDeleteAnimationBody( + for: deletedIdentifiers + ) + } + vanishAnimator.startAnimation() + + // If all media is deleted, close the viewer + guard let pagingAfterDeletion else { + assert(mediaViewerVM.mediaIdentifiers.isEmpty) + navigationController?.popViewController(animated: true) + return + } + + await vanishAnimator.addCompletion() + + // MARK: Finalize deletion + + let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { + self.pageControlBar.deleteItems( + deletedIdentifiers, + destinationIdentifier: pagingAfterDeletion.destinationIdentifier, + animated: true + ) + + if let direction = pagingAfterDeletion.direction { + self.move( + toMediaWith: pagingAfterDeletion.destinationIdentifier, + direction: direction, + animated: true + ) + } + } + finishAnimator.startAnimation() + await finishAnimator.addCompletion() } /// Deletes media with the specified identifier. From 0cf1dc619bda760d2946a11f4e1e2880cf14c9a3 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 23 Nov 2023 17:04:03 +0900 Subject: [PATCH 081/141] change: deleteItems(...) => loadItems(...) --- .../MediaViewerViewController.swift | 12 ++-- .../MediaViewerPageControlBar.swift | 62 +++++++++---------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 4ef4dcfe..b6b8fb66 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -519,9 +519,9 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Finalize deletion let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { - self.pageControlBar.deleteItems( - deletedIdentifiers, - destinationIdentifier: pagingAfterDeletion.destinationIdentifier, + self.pageControlBar.loadItems( + self.mediaViewerVM.mediaIdentifiers, + expandingItemWith: pagingAfterDeletion.destinationIdentifier, animated: true ) @@ -611,9 +611,9 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Finalize deletion let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { - self.pageControlBar.deleteItems( - [identifier], - destinationIdentifier: pagingAfterDeletion.destinationIdentifier, + self.pageControlBar.loadItems( + self.mediaViewerVM.mediaIdentifiers, + expandingItemWith: pagingAfterDeletion.destinationIdentifier, animated: true ) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index bde305f7..d241cb83 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -212,6 +212,34 @@ final class MediaViewerPageControlBar: UIView { } } + /// Loads identifiers for media. + /// - Parameters: + /// - identifiers: Identifiers for media to load. + /// - expandingIdentifier: An identifier for media to expand after the loading. + /// - animated: Whether to animate the loading. + func loadItems( + _ identifiers: [AnyMediaIdentifier], + expandingItemWith expandingIdentifier: AnyMediaIdentifier, + animated: Bool + ) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(identifiers) + diffableDataSource.apply(snapshot, animatingDifferences: animated) + + guard let indexPath = diffableDataSource.indexPath(for: expandingIdentifier) else { + return + } + updateLayout( + expandingItemAt: indexPath, + expandingThumbnailWidthToHeight: dataSource?.mediaViewerPageControlBar( + self, + widthToHeightOfThumbnailWith: expandingIdentifier + ), + animated: animated + ) + } + private func page(with identifier: AnyMediaIdentifier) -> Int? { diffableDataSource.snapshot().indexOfItem(identifier) } @@ -384,7 +412,7 @@ extension MediaViewerPageControlBar { /// /// This method itself does not animate, so call it in an animation block. /// It also does not update the data source so you have to call - /// `deleteItems(_:animated:)` after this animation is finished. + /// `loadItems(_:expandingItemWith:animated:)` after this animation is finished. /// /// - Parameter identifiers: Identifiers for media to perform delete animation. func performDeleteAnimationBody(for identifiers: [AnyMediaIdentifier]) { @@ -394,38 +422,6 @@ extension MediaViewerPageControlBar { cell(for: identifier)?.performDeleteAnimationBody() } } - - /// Deletes specified items. - /// - /// This method updates the data source. - /// - /// - Parameters: - /// - identifiers: Identifiers for media to delete. - /// - destinationIdentifier: An identifier for media to move to after deletion. - /// - animated: Whether to animate the deletion. - func deleteItems( - _ identifiers: [AnyMediaIdentifier], - destinationIdentifier: AnyMediaIdentifier, - animated: Bool - ) { - assert(state == .deleting) - - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems(identifiers) - diffableDataSource.apply(snapshot, animatingDifferences: animated) - - guard let indexPath = diffableDataSource.indexPath(for: destinationIdentifier) else { - return - } - updateLayout( - expandingItemAt: indexPath, - expandingThumbnailWidthToHeight: dataSource?.mediaViewerPageControlBar( - self, - widthToHeightOfThumbnailWith: destinationIdentifier - ), - animated: animated - ) - } } // MARK: - Interactive paging - From 9ae6a333dfa5921728e5bed2361f61ca202e91ef Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 23 Nov 2023 17:14:06 +0900 Subject: [PATCH 082/141] change: use destinationPageVCAfterDeletion as paging destination --- .../MediaViewer/MediaViewerViewController.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b6b8fb66..86296d94 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -413,13 +413,13 @@ open class MediaViewerViewController: UIPageViewController { ) } - func move( + private func move( toMediaWith identifier: AnyMediaIdentifier, animated: Bool, completion: ((Bool) -> Void)? = nil ) { move( - toMediaWith: identifier, + to: makeMediaViewerPage(with: identifier), direction: mediaViewerVM.moveDirection( from: currentMediaIdentifier, to: identifier @@ -430,13 +430,13 @@ open class MediaViewerViewController: UIPageViewController { } private func move( - toMediaWith identifier: AnyMediaIdentifier, + to mediaViewerPage: MediaViewerOnePageViewController, direction: NavigationDirection, animated: Bool, completion: ((Bool) -> Void)? = nil ) { setViewControllers( - [makeMediaViewerPage(with: identifier)], + [mediaViewerPage], direction: direction, animated: animated, completion: completion @@ -525,9 +525,10 @@ open class MediaViewerViewController: UIPageViewController { animated: true ) - if let direction = pagingAfterDeletion.direction { + if let direction = pagingAfterDeletion.direction, + let destination = self.destinationPageVCAfterDeletion { self.move( - toMediaWith: pagingAfterDeletion.destinationIdentifier, + to: destination, direction: direction, animated: true ) @@ -626,7 +627,9 @@ open class MediaViewerViewController: UIPageViewController { * to reference a deleted page. */ self.move( - toMediaWith: pagingAfterDeletion.destinationIdentifier, + to: self.makeMediaViewerPage( + with: pagingAfterDeletion.destinationIdentifier + ), direction: direction, animated: true ) From 70961dddedd781ca59dd0490179518badae9dcbc Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 23 Nov 2023 17:16:02 +0900 Subject: [PATCH 083/141] update: allow to delete during the previous deletion --- .../MediaViewer/MediaViewerViewController.swift | 14 +++++++++----- .../PageControlBar/MediaViewerPageControlBar.swift | 3 ++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 86296d94..31bc3ed5 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -475,7 +475,6 @@ open class MediaViewerViewController: UIPageViewController { visibleVCBeforeDeletion: visibleVCBeforeDeletion, pagingAfterDeletion: pagingAfterDeletion ) - destinationPageVCAfterDeletion = nil } private func insertMedia(with identifiers: [AnyMediaIdentifier]) async { @@ -488,7 +487,6 @@ open class MediaViewerViewController: UIPageViewController { pagingAfterDeletion: MediaViewerViewModel.PagingAfterDeletion? ) async { await pageControlBar.beginDeletion() - defer { pageControlBar.finishDeletion() } let isVisibleMediaDeleted = deletedIdentifiers.contains( visibleVCBeforeDeletion.mediaIdentifier @@ -518,15 +516,19 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Finalize deletion + guard let destination = destinationPageVCAfterDeletion, + pagingAfterDeletion.destinationIdentifier == destination.mediaIdentifier else { + return + } + let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { self.pageControlBar.loadItems( self.mediaViewerVM.mediaIdentifiers, - expandingItemWith: pagingAfterDeletion.destinationIdentifier, + expandingItemWith: destination.mediaIdentifier, animated: true ) - if let direction = pagingAfterDeletion.direction, - let destination = self.destinationPageVCAfterDeletion { + if let direction = pagingAfterDeletion.direction { self.move( to: destination, direction: direction, @@ -536,6 +538,8 @@ open class MediaViewerViewController: UIPageViewController { } finishAnimator.startAnimation() await finishAnimator.addCompletion() + pageControlBar.finishDeletion() + destinationPageVCAfterDeletion = nil } /// Deletes media with the specified identifier. diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index d241cb83..6bcfec6b 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -398,7 +398,8 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { func beginDeletion() async { - while state != .expanded { + let readyStates: [State] = [.expanded, .deleting] + while !readyStates.contains(state) { await Task.yield() } state = .deleting From b43402f4389cb4e4a3c6172fd9ae9fb307950fce Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 23 Nov 2023 22:12:01 +0900 Subject: [PATCH 084/141] change: vanish animation --- Sources/MediaViewer/MediaViewerViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 31bc3ed5..827338db 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -495,7 +495,7 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Perform vanish animation - let vanishAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { + let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { if isVisibleMediaDeleted { visiblePageView.performDeleteAnimationBody() } @@ -595,7 +595,7 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Perform delete animation - let deletionAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { + let deletionAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { if identifier == currentPageVC.mediaIdentifier { let currentPageView = currentPageVC.mediaViewerOnePageView currentPageView.performDeleteAnimationBody() From c991fa8313807007b7e90aefe759bb6a337bd649 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 23 Nov 2023 22:14:29 +0900 Subject: [PATCH 085/141] rename: delete => vanish --- .../MediaViewerOnePageView.swift | 2 +- .../MediaViewer/MediaViewerViewController.swift | 16 ++++++++-------- .../MediaViewerPageControlBar.swift | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift index a0c0d25c..542278fc 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift @@ -160,7 +160,7 @@ final class MediaViewerOnePageView: UIView { scrollView.zoom(to: zoomArea, animated: animated) } - func performDeleteAnimationBody() { + func performVanishAnimationBody() { imageView.transform = imageView.transform.scaledBy(x: 0.5, y: 0.5) imageView.alpha = 0 } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 827338db..e704998a 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -497,9 +497,9 @@ open class MediaViewerViewController: UIPageViewController { let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { if isVisibleMediaDeleted { - visiblePageView.performDeleteAnimationBody() + visiblePageView.performVanishAnimationBody() } - self.pageControlBar.performDeleteAnimationBody( + self.pageControlBar.performVanishAnimationBody( for: deletedIdentifiers ) } @@ -593,16 +593,16 @@ open class MediaViewerViewController: UIPageViewController { "You have to complete deletion in `deleteAction` closure." ) - // MARK: Perform delete animation + // MARK: Perform vanish animation - let deletionAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { + let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { if identifier == currentPageVC.mediaIdentifier { let currentPageView = currentPageVC.mediaViewerOnePageView - currentPageView.performDeleteAnimationBody() + currentPageView.performVanishAnimationBody() } - self.pageControlBar.performDeleteAnimationBody(for: [identifier]) + self.pageControlBar.performVanishAnimationBody(for: [identifier]) } - deletionAnimator.startAnimation() + vanishAnimator.startAnimation() // If all media is deleted, close the viewer guard let pagingAfterDeletion else { @@ -611,7 +611,7 @@ open class MediaViewerViewController: UIPageViewController { return } - await deletionAnimator.addCompletion() + await vanishAnimator.addCompletion() // MARK: Finalize deletion diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 6bcfec6b..7a14ce54 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -409,14 +409,14 @@ extension MediaViewerPageControlBar { state = .expanded } - /// Performs the body of the delete animation. + /// Performs the body of the vanish animation. /// /// This method itself does not animate, so call it in an animation block. /// It also does not update the data source so you have to call /// `loadItems(_:expandingItemWith:animated:)` after this animation is finished. /// - /// - Parameter identifiers: Identifiers for media to perform delete animation. - func performDeleteAnimationBody(for identifiers: [AnyMediaIdentifier]) { + /// - Parameter identifiers: Identifiers for media to perform vanish animation. + func performVanishAnimationBody(for identifiers: [AnyMediaIdentifier]) { assert(state == .deleting) for identifier in identifiers { From 848d2afab8830c7746c6093f6d80a526a0e3d71b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 19:45:17 +0900 Subject: [PATCH 086/141] add: visiblePageViewController --- .../MediaViewer/MediaViewerViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index e704998a..fe5680f5 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -69,12 +69,20 @@ open class MediaViewerViewController: UIPageViewController { currentPageViewController.mediaIdentifier } - private var destinationPageVCAfterDeletion: MediaViewerOnePageViewController? - + /// A view controller for the current page. + /// + /// During deletion, `visiblePageViewController` returns the page that was displayed + /// just before deletion, while `currentPageViewController` returns the page that will be + /// displayed eventually. var currentPageViewController: MediaViewerOnePageViewController { if let destinationPageVCAfterDeletion { return destinationPageVCAfterDeletion } + return visiblePageViewController + } + + /// A view controller for the currently visible page. + var visiblePageViewController: MediaViewerOnePageViewController { guard let mediaViewerOnePage = viewControllers?.first as? MediaViewerOnePageViewController else { preconditionFailure( "\(Self.self) must have only one \(MediaViewerOnePageViewController.self)." @@ -83,6 +91,8 @@ open class MediaViewerViewController: UIPageViewController { return mediaViewerOnePage } + private var destinationPageVCAfterDeletion: MediaViewerOnePageViewController? + public var isShowingMediaOnly: Bool { mediaViewerVM.showsMediaOnly } From e135e9a1190dbbd34b1248d5241d9ba4e0b806ef Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 19:52:09 +0900 Subject: [PATCH 087/141] add: fetchMediaIdentifiers() --- Sources/MediaViewer/MediaViewerViewController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index fe5680f5..e074b381 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -405,6 +405,13 @@ open class MediaViewerViewController: UIPageViewController { // MARK: - Methods + /// Fetches type-erased identifiers for media from the data source. + func fetchMediaIdentifiers() -> [AnyMediaIdentifier] { + mediaViewerDataSource + .mediaIdentifiers(for: self) + .map { AnyMediaIdentifier(rawValue: $0) } + } + /// Move to media with the specified identifier. /// - Parameters: /// - identifier: An identifier for destination media. @@ -454,9 +461,7 @@ open class MediaViewerViewController: UIPageViewController { } open func reloadMedia() async { - let newIdentifiers = mediaViewerDataSource - .mediaIdentifiers(for: self) - .map { AnyMediaIdentifier(rawValue: $0) } + let newIdentifiers = fetchMediaIdentifiers() let (insertions, removals) = newIdentifiers.difference( from: mediaViewerVM.mediaIdentifiers From 848192c330abc30c788fda4e0e6b71bbbd1d82de Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 19:53:41 +0900 Subject: [PATCH 088/141] add: assertion --- Sources/MediaViewer/MediaViewerViewController.swift | 2 ++ .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index e074b381..88bef8f9 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -490,6 +490,8 @@ open class MediaViewerViewController: UIPageViewController { visibleVCBeforeDeletion: visibleVCBeforeDeletion, pagingAfterDeletion: pagingAfterDeletion ) + + assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) } private func insertMedia(with identifiers: [AnyMediaIdentifier]) async { diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 7a14ce54..93d415c0 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -406,6 +406,7 @@ extension MediaViewerPageControlBar { } func finishDeletion() { + assert(state == .deleting) state = .expanded } From b08c6663a04308b7f902f0fa7e94e02fc7d46740 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:11:24 +0900 Subject: [PATCH 089/141] chore: improve test --- .../MediaViewerViewModelTests.swift | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index 53cbf906..2a41d63e 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -79,23 +79,47 @@ final class MediaViewerViewModelTests: XCTestCase { } XCTContext.runActivity( - named: "When a non-current page is deleted, the viewer should stay on the current page" + named: "When the current page is not deleted, the viewer should stay on the current page" ) { _ in - // Act - let pagingAfterDeletion = mediaViewerVM.paging( - afterDeleting: [identifiers[1], identifiers[4]], - currentIdentifier: identifiers[2] - ) + XCTContext.runActivity( + named: "When some non-current pages are deleted" + ) { _ in + // Act + let pagingAfterDeletion = mediaViewerVM.paging( + afterDeleting: [identifiers[1], identifiers[4]], + currentIdentifier: identifiers[2] + ) + + // Assert + XCTAssertEqual( + pagingAfterDeletion, + .init( + // Current page + destinationIdentifier: identifiers[2], + direction: nil + ) + ) + } - // Assert - XCTAssertEqual( - pagingAfterDeletion, - .init( - // Current page - destinationIdentifier: identifiers[2], - direction: nil + XCTContext.runActivity( + named: "When no pages are deleted" + ) { _ in + // Act + let pagingAfterDeletion = mediaViewerVM.paging( + afterDeleting: [], + currentIdentifier: identifiers[2] ) - ) + + // Assert + XCTAssertEqual( + pagingAfterDeletion, + .init( + // Current page + destinationIdentifier: identifiers[2], + direction: nil + ) + ) + } } } } From 4ef8a9ca845d9aa74f4f0f89a63d8e319fd77dbd Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:20:51 +0900 Subject: [PATCH 090/141] chore: refactor --- .../MediaViewer/MediaViewerViewController.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 88bef8f9..c4775f04 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -533,8 +533,19 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Finalize deletion - guard let destination = destinationPageVCAfterDeletion, - pagingAfterDeletion.destinationIdentifier == destination.mediaIdentifier else { + guard let destination = destinationPageVCAfterDeletion else { + assertionFailure( + "destinationPageVCAfterDeletion should not be nil until all delete transactions have completed." + ) + return + } + + guard pagingAfterDeletion.destinationIdentifier == destination.mediaIdentifier else { + /* + * NOTE: + * Do not run finishAnimator because another delete transaction + * will follow. + */ return } From 96ce73cd2eaeb54ffa15f28e832b8e77a8f01d95 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:40:21 +0900 Subject: [PATCH 091/141] fix: skip finishing if there are still delete transactions --- .../MediaViewer/MediaViewerViewController.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index c4775f04..0f17aa43 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -566,8 +566,20 @@ open class MediaViewerViewController: UIPageViewController { } finishAnimator.startAnimation() await finishAnimator.addCompletion() - pageControlBar.finishDeletion() - destinationPageVCAfterDeletion = nil + + /* + * NOTE: + * If another deletion occurs while finishAnimator is running, + * destinationPageVCAfterDeletion is overwritten with the new value + * and takes a different value from visiblePageViewController. + */ + let isAllDeletionCompleted = visiblePageViewController == destinationPageVCAfterDeletion + if isAllDeletionCompleted { + destinationPageVCAfterDeletion = nil + pageControlBar.finishDeletion() + } else { + // NOTE: Do not finish because there are still delete transactions. + } } /// Deletes media with the specified identifier. From 21f6b5ae8a112f50a9191e3d477020932a010736 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:41:22 +0900 Subject: [PATCH 092/141] add: MediaViewerViewController.currentMediaIdentifier(as:) --- Sources/MediaViewer/MediaViewerViewController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 0f17aa43..e6b9bf90 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -65,6 +65,18 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.page(with: currentMediaIdentifier)! } + public func currentMediaIdentifier( + as identifierType: MediaIdentifier.Type = MediaIdentifier.self + ) -> MediaIdentifier { + let rawIdentifier = currentMediaIdentifier.rawValue + guard let identifier = rawIdentifier as? MediaIdentifier else { + preconditionFailure( + "The type of media identifier is \(type(of: rawIdentifier.base)), not \(identifierType)." + ) + } + return identifier + } + var currentMediaIdentifier: AnyMediaIdentifier { currentPageViewController.mediaIdentifier } From 50aa7852b0ddbbdcf74fc820f0e54278942970b8 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:43:58 +0900 Subject: [PATCH 093/141] change: trashButton implementation --- .../Extensions/MediaViewerViewController+UI.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index a7fc6a90..7fa43d10 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -27,13 +27,11 @@ extension MediaViewerViewController { ) -> UIBarButtonItem where MediaIdentifier: Hashable { .init(systemItem: .trash, primaryAction: .init { [weak self] action in guard let self else { return } - let button = action.sender as? UIBarButtonItem - button?.isEnabled = false Task { - defer { button?.isEnabled = true } - try await self.deleteCurrentMedia(after: { currentMediaIdentifier in - try await deleteAction(currentMediaIdentifier) - }) + try await deleteAction( + self.currentMediaIdentifier(as: MediaIdentifier.self) + ) + await self.reloadMedia() } }) } From 0ebe27f0f7ae41e0459305ba92dde2c0eb2b793b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:45:55 +0900 Subject: [PATCH 094/141] delete: deprecated methods --- .../MediaViewerViewController.swift | 133 ------------------ .../MediaViewer/MediaViewerViewModel.swift | 5 - 2 files changed, 138 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index e6b9bf90..4d582e08 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -594,139 +594,6 @@ open class MediaViewerViewController: UIPageViewController { } } - /// Deletes media with the specified identifier. - /// - /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. If all media is deleted, the viewer will close. - /// - /// ```swift - /// try mediaViewer.deleteMedia(with: imageIdentifier, after: { - /// try await your.deleteImage(with: imageIdentifier) - /// }) - /// ``` - /// - /// - Note: `deleteAction` must complete deletion until it returns. - /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. - /// If the deletion fails, `deleteAction` must throw an error. - /// - Parameters: - /// - identifier: An identifier for media to delete. - /// - deleteAction: A closure that performs the actual media deletion. - /// It must complete deletion until it returns. - /// - Throws: If the viewer is not ready to delete (e.g. during paging or delete animation), - /// `DeletionError.notReadyToDelete` will be thrown. - /// If `deleteAction` throws some error, it will be thrown. - open func deleteMedia( - with identifier: MediaIdentifier, - after deleteAction: () async throws -> Void - ) async rethrows where MediaIdentifier: Hashable { - await pageControlBar.beginDeletion() - defer { pageControlBar.finishDeletion() } - - let identifier = AnyMediaIdentifier(rawValue: identifier) - let currentPageVC = currentPageViewController - - let pagingAfterDeletion = mediaViewerVM.paging( - afterDeleting: [identifier], - currentIdentifier: currentPageVC.mediaIdentifier - ) - - // MARK: Delete media - - let identifiers = mediaViewerDataSource.mediaIdentifiers(for: self) - precondition( - mediaViewerVM.mediaIdentifiers.count == identifiers.count - ) - - try await deleteAction() - mediaViewerVM.deleteMediaIdentifier(identifier) - - let identifiersAfterDeletion = mediaViewerDataSource.mediaIdentifiers(for: self) - assert( - mediaViewerVM.mediaIdentifiers.count == identifiersAfterDeletion.count, - "You have to complete deletion in `deleteAction` closure." - ) - - // MARK: Perform vanish animation - - let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { - if identifier == currentPageVC.mediaIdentifier { - let currentPageView = currentPageVC.mediaViewerOnePageView - currentPageView.performVanishAnimationBody() - } - self.pageControlBar.performVanishAnimationBody(for: [identifier]) - } - vanishAnimator.startAnimation() - - // If all media is deleted, close the viewer - guard let pagingAfterDeletion else { - assert(identifiersAfterDeletion.isEmpty) - navigationController?.popViewController(animated: true) - return - } - - await vanishAnimator.addCompletion() - - // MARK: Finalize deletion - - let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { - self.pageControlBar.loadItems( - self.mediaViewerVM.mediaIdentifiers, - expandingItemWith: pagingAfterDeletion.destinationIdentifier, - animated: true - ) - - // Move page if deleted an image on the current page - if let direction = pagingAfterDeletion.direction { - /* - * NOTE: - * move(toPage:animated:) does not work here. - * That method uses currentPage, which may crash as it tries - * to reference a deleted page. - */ - self.move( - to: self.makeMediaViewerPage( - with: pagingAfterDeletion.destinationIdentifier - ), - direction: direction, - animated: true - ) - } - } - finishAnimator.startAnimation() - await finishAnimator.addCompletion() - } - - /// Deletes media on the current page. - /// - /// This method calls the specified `deleteAction`, and if it succeeds, performs the delete animation. - /// - /// ```swift - /// try mediaViewer.deleteCurrentMedia(after: { currentImageIdentifier in - /// try await your.deleteImage(with: currentImageIdentifier) - /// }) - /// ``` - /// - /// If you want to provide the deletion UI in an easy way, you can use `trashButton(deleteAction:)` instead. - /// - /// - Note: `deleteAction` must complete deletion until it returns. - /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. - /// If the deletion fails, `deleteAction` must throw an error. - /// - Parameter deleteAction: A closure that takes the current media identifier and - /// performs the actual media deletion. - /// It must complete deletion until it returns. - /// - Throws: If the viewer is not ready to delete (e.g. during paging or delete animation), - /// `DeletionError.notReadyToDelete` will be thrown. - /// If `deleteAction` throws some error, it will be thrown. - open func deleteCurrentMedia( - after deleteAction: ( - _ currentMediaIdentifier: MediaIdentifier - ) async throws -> Void - ) async throws where MediaIdentifier: Hashable { - let currentIdentifier = self.currentMediaIdentifier.rawValue as! MediaIdentifier - try await deleteMedia(with: currentIdentifier, after: { - try await deleteAction(currentIdentifier) - }) - } - private func pageDidChange() { mediaViewerDelegate?.mediaViewer( self, diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 4f266a16..95bb7054 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -58,11 +58,6 @@ final class MediaViewerViewModel: ObservableObject { extension MediaViewerViewModel { - func deleteMediaIdentifier(_ identifier: AnyMediaIdentifier) { - guard let page = page(with: identifier) else { return } - mediaIdentifiers.remove(at: page) - } - struct PagingAfterDeletion: Hashable { let destinationIdentifier: AnyMediaIdentifier let direction: UIPageViewController.NavigationDirection? From 015159de34782f4a36f2a07a06afe203eda669d7 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Fri, 24 Nov 2023 20:51:20 +0900 Subject: [PATCH 095/141] update: skip if there is no changes --- Sources/MediaViewer/MediaViewerViewController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 4d582e08..b56ffbe2 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -506,7 +506,10 @@ open class MediaViewerViewController: UIPageViewController { assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) } - private func insertMedia(with identifiers: [AnyMediaIdentifier]) async { + private func insertMedia( + with insertedIdentifiers: [AnyMediaIdentifier] + ) async { + guard !insertedIdentifiers.isEmpty else { return } fatalError("Not implemented.") // TODO: implement } @@ -515,6 +518,8 @@ open class MediaViewerViewController: UIPageViewController { visibleVCBeforeDeletion: MediaViewerOnePageViewController, pagingAfterDeletion: MediaViewerViewModel.PagingAfterDeletion? ) async { + guard !deletedIdentifiers.isEmpty else { return } + await pageControlBar.beginDeletion() let isVisibleMediaDeleted = deletedIdentifiers.contains( From d5bf169c826fec483a8d7db6b4480c1633e0638d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 19:57:40 +0900 Subject: [PATCH 096/141] chore: omit type --- .../MediaViewer/Extensions/MediaViewerViewController+UI.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index 7fa43d10..d4733e5c 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -28,9 +28,7 @@ extension MediaViewerViewController { .init(systemItem: .trash, primaryAction: .init { [weak self] action in guard let self else { return } Task { - try await deleteAction( - self.currentMediaIdentifier(as: MediaIdentifier.self) - ) + try await deleteAction(self.currentMediaIdentifier()) await self.reloadMedia() } }) From 8d77de7c422aaf9940e347c47e16b3ab2494f6a1 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:05:48 +0900 Subject: [PATCH 097/141] docs: comment for currentMediaIdentifier(as:) --- Sources/MediaViewer/MediaViewerViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b56ffbe2..725086ad 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -65,6 +65,9 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.page(with: currentMediaIdentifier)! } + /// Returns the identifier for currently viewing media in the viewer. + /// - Parameter identifierType: A type of the identifier for media. + /// It must match the one provided by `mediaViewerDataSource`. public func currentMediaIdentifier( as identifierType: MediaIdentifier.Type = MediaIdentifier.self ) -> MediaIdentifier { From 684a70403b1f747c04c8b1c439a5c2e9d34bfb16 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sat, 25 Nov 2023 18:46:17 +0900 Subject: [PATCH 098/141] fix: send pageDidChange when loadItems is called --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 725086ad..681f8e08 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -326,7 +326,7 @@ open class MediaViewerViewController: UIPageViewController { case .tapOnPageThumbnail, .scrollingBar: let identifier = mediaViewerVM.mediaIdentifier(forPage: page)! move(toMediaWith: identifier, animated: false) - case .configuration, .interactivePaging: + case .configuration, .load, .interactivePaging: // Do nothing because it has already been moved to the page. break } diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 93d415c0..2c24c070 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -76,6 +76,7 @@ final class MediaViewerPageControlBar: UIView { /// What caused the page change. enum PageChangeReason: Hashable { case configuration + case load case tapOnPageThumbnail case scrollingBar case interactivePaging @@ -230,6 +231,7 @@ final class MediaViewerPageControlBar: UIView { guard let indexPath = diffableDataSource.indexPath(for: expandingIdentifier) else { return } + _pageDidChange.send((page: indexPath.item, reason: .load)) updateLayout( expandingItemAt: indexPath, expandingThumbnailWidthToHeight: dataSource?.mediaViewerPageControlBar( From e99d243b0954b63bf5dfa8151b406c1964c69b38 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 04:03:44 +0900 Subject: [PATCH 099/141] change: early return if there is no difference --- Sources/MediaViewer/MediaViewerViewController.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 681f8e08..88cfe42b 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -478,9 +478,12 @@ open class MediaViewerViewController: UIPageViewController { open func reloadMedia() async { let newIdentifiers = fetchMediaIdentifiers() - let (insertions, removals) = newIdentifiers.difference( + let difference = newIdentifiers.difference( from: mediaViewerVM.mediaIdentifiers - ).changes + ) + guard !difference.isEmpty else { return } + + let (insertions, removals) = difference.changes let deletingIdentifiers = removals.map(\.element) let visibleVCBeforeDeletion = currentPageViewController From 8f7c815124adb95e158f0ad62302ca87bedfafe0 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 04:12:16 +0900 Subject: [PATCH 100/141] change: timing to begin/finish deletion --- .../MediaViewerViewController.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 88cfe42b..69f66b27 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -501,6 +501,8 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.mediaIdentifiers = newIdentifiers + await pageControlBar.beginDeletion() + // TODO: Run animations at the same time await insertMedia(with: insertions.map(\.element)) await deleteMedia( @@ -509,6 +511,20 @@ open class MediaViewerViewController: UIPageViewController { pagingAfterDeletion: pagingAfterDeletion ) + /* + * NOTE: + * If another deletion occurs during deletion, + * destinationPageVCAfterDeletion is overwritten with the new value + * and takes a different value from visiblePageViewController. + */ + let isAllDeletionCompleted = visiblePageViewController.mediaIdentifier == destinationPageVCAfterDeletion?.mediaIdentifier + if isAllDeletionCompleted { + destinationPageVCAfterDeletion = nil + pageControlBar.finishDeletion() + } else { + // NOTE: Do not finish because there are still delete transactions. + } + assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) } @@ -526,8 +542,6 @@ open class MediaViewerViewController: UIPageViewController { ) async { guard !deletedIdentifiers.isEmpty else { return } - await pageControlBar.beginDeletion() - let isVisibleMediaDeleted = deletedIdentifiers.contains( visibleVCBeforeDeletion.mediaIdentifier ) @@ -589,20 +603,6 @@ open class MediaViewerViewController: UIPageViewController { } finishAnimator.startAnimation() await finishAnimator.addCompletion() - - /* - * NOTE: - * If another deletion occurs while finishAnimator is running, - * destinationPageVCAfterDeletion is overwritten with the new value - * and takes a different value from visiblePageViewController. - */ - let isAllDeletionCompleted = visiblePageViewController == destinationPageVCAfterDeletion - if isAllDeletionCompleted { - destinationPageVCAfterDeletion = nil - pageControlBar.finishDeletion() - } else { - // NOTE: Do not finish because there are still delete transactions. - } } private func pageDidChange() { From 2e0223b11fe44dc6006e39b008551c1b0f838a14 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 04:17:59 +0900 Subject: [PATCH 101/141] rename: delete => reload --- .../MediaViewerViewController.swift | 50 +++++++++---------- .../MediaViewer/MediaViewerViewModel.swift | 6 +-- .../MediaViewerPageControlBar.swift | 20 ++++---- .../MediaViewerViewModelTests.swift | 22 ++++---- 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 69f66b27..61590028 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -86,12 +86,12 @@ open class MediaViewerViewController: UIPageViewController { /// A view controller for the current page. /// - /// During deletion, `visiblePageViewController` returns the page that was displayed - /// just before deletion, while `currentPageViewController` returns the page that will be + /// During reloading, `visiblePageViewController` returns the page that was displayed + /// just before reloading, while `currentPageViewController` returns the page that will be /// displayed eventually. var currentPageViewController: MediaViewerOnePageViewController { - if let destinationPageVCAfterDeletion { - return destinationPageVCAfterDeletion + if let destinationPageVCAfterReloading { + return destinationPageVCAfterReloading } return visiblePageViewController } @@ -106,7 +106,7 @@ open class MediaViewerViewController: UIPageViewController { return mediaViewerOnePage } - private var destinationPageVCAfterDeletion: MediaViewerOnePageViewController? + private var destinationPageVCAfterReloading: MediaViewerOnePageViewController? public var isShowingMediaOnly: Bool { mediaViewerVM.showsMediaOnly @@ -486,43 +486,43 @@ open class MediaViewerViewController: UIPageViewController { let (insertions, removals) = difference.changes let deletingIdentifiers = removals.map(\.element) - let visibleVCBeforeDeletion = currentPageViewController + let visibleVCBeforeReloading = currentPageViewController // TODO: Consider insertions - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: deletingIdentifiers, - currentIdentifier: visibleVCBeforeDeletion.mediaIdentifier + currentIdentifier: visibleVCBeforeReloading.mediaIdentifier ) - if let pagingAfterDeletion { - destinationPageVCAfterDeletion = makeMediaViewerPage( - with: pagingAfterDeletion.destinationIdentifier + if let pagingAfterReloading { + destinationPageVCAfterReloading = makeMediaViewerPage( + with: pagingAfterReloading.destinationIdentifier ) } mediaViewerVM.mediaIdentifiers = newIdentifiers - await pageControlBar.beginDeletion() + await pageControlBar.startReloading() // TODO: Run animations at the same time await insertMedia(with: insertions.map(\.element)) await deleteMedia( with: deletingIdentifiers, - visibleVCBeforeDeletion: visibleVCBeforeDeletion, - pagingAfterDeletion: pagingAfterDeletion + visibleVCBeforeDeletion: visibleVCBeforeReloading, + pagingAfterDeletion: pagingAfterReloading ) /* * NOTE: - * If another deletion occurs during deletion, - * destinationPageVCAfterDeletion is overwritten with the new value + * If another reloading occurs during the reloading, + * destinationPageVCAfterReloading is overwritten with the new value * and takes a different value from visiblePageViewController. */ - let isAllDeletionCompleted = visiblePageViewController.mediaIdentifier == destinationPageVCAfterDeletion?.mediaIdentifier - if isAllDeletionCompleted { - destinationPageVCAfterDeletion = nil - pageControlBar.finishDeletion() + let isAllReloadingCompleted = visiblePageViewController.mediaIdentifier == destinationPageVCAfterReloading?.mediaIdentifier + if isAllReloadingCompleted { + destinationPageVCAfterReloading = nil + pageControlBar.finishReloading() } else { - // NOTE: Do not finish because there are still delete transactions. + // NOTE: Do not finish because there are still reload transactions. } assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) @@ -538,7 +538,7 @@ open class MediaViewerViewController: UIPageViewController { private func deleteMedia( with deletedIdentifiers: [AnyMediaIdentifier], visibleVCBeforeDeletion: MediaViewerOnePageViewController, - pagingAfterDeletion: MediaViewerViewModel.PagingAfterDeletion? + pagingAfterDeletion: MediaViewerViewModel.PagingAfterReloading? ) async { guard !deletedIdentifiers.isEmpty else { return } @@ -570,9 +570,9 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Finalize deletion - guard let destination = destinationPageVCAfterDeletion else { + guard let destination = destinationPageVCAfterReloading else { assertionFailure( - "destinationPageVCAfterDeletion should not be nil until all delete transactions have completed." + "destinationPageVCAfterReloading should not be nil until all reloading transactions have completed." ) return } @@ -633,7 +633,7 @@ open class MediaViewerViewController: UIPageViewController { if progress != 0 { pageControlBar.startInteractivePaging(forwards: isMovingToNextPage) } - case .deleting: + case .reloading: break } } diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 95bb7054..403127f9 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -54,11 +54,11 @@ final class MediaViewerViewModel: ObservableObject { } } -// MARK: - Deletion - +// MARK: - Reloading - extension MediaViewerViewModel { - struct PagingAfterDeletion: Hashable { + struct PagingAfterReloading: Hashable { let destinationIdentifier: AnyMediaIdentifier let direction: UIPageViewController.NavigationDirection? } @@ -66,7 +66,7 @@ extension MediaViewerViewModel { func paging( afterDeleting deletingIdentifiers: [AnyMediaIdentifier], currentIdentifier: AnyMediaIdentifier - ) -> PagingAfterDeletion? { + ) -> PagingAfterReloading? { guard deletingIdentifiers.contains(currentIdentifier) else { // Stay on the current page return .init( diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 2c24c070..07e07934 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -38,7 +38,7 @@ final class MediaViewerPageControlBar: UIView { /// The state of interactively transitioning between pages. case transitioningInteractively(UICollectionViewTransitionLayout, forwards: Bool) - case deleting + case reloading var indexPathForFinalDestinationItem: IndexPath? { guard case .collapsed(let indexPath) = self else { return nil } @@ -399,16 +399,16 @@ final class MediaViewerPageControlBar: UIView { extension MediaViewerPageControlBar { - func beginDeletion() async { - let readyStates: [State] = [.expanded, .deleting] + func startReloading() async { + let readyStates: [State] = [.expanded, .reloading] while !readyStates.contains(state) { await Task.yield() } - state = .deleting + state = .reloading } - func finishDeletion() { - assert(state == .deleting) + func finishReloading() { + assert(state == .reloading) state = .expanded } @@ -420,7 +420,7 @@ extension MediaViewerPageControlBar { /// /// - Parameter identifiers: Identifiers for media to perform vanish animation. func performVanishAnimationBody(for identifiers: [AnyMediaIdentifier]) { - assert(state == .deleting) + assert(state == .reloading) for identifier in identifiers { cell(for: identifier)?.performDeleteAnimationBody() @@ -507,7 +507,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) - guard state != .deleting else { return } + guard state != .reloading else { return } if case .normal(let barLayout) = layout, barLayout.style.indexPathForExpandingItem != indexPath { @@ -543,7 +543,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { !isEdgeIndexPath(indexPathForCurrentCenterItem) { expandAndScrollToCenterItem(animated: true, causingBy: .scrollingBar) } - case .collapsing, .expanding, .expanded, .transitioningInteractively, .deleting: + case .collapsing, .expanding, .expanded, .transitioningInteractively, .reloading: break } } @@ -592,7 +592,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { switch state { - case .collapsing, .collapsed, .deleting: + case .collapsing, .collapsed, .reloading: expandAndScrollToCenterItem(animated: true, causingBy: .scrollingBar) case .expanding, .expanded, .transitioningInteractively: break // NOP diff --git a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift index 2a41d63e..90111770 100644 --- a/Tests/MediaViewerTests/MediaViewerViewModelTests.swift +++ b/Tests/MediaViewerTests/MediaViewerViewModelTests.swift @@ -16,7 +16,7 @@ final class MediaViewerViewModelTests: XCTestCase { mediaViewerVM = .init() } - func testPagingAfterDeletion() { + func testPagingAfterReloading() { // Arrange let identifiers = (0..<5).map(AnyMediaIdentifier.init) mediaViewerVM.mediaIdentifiers = identifiers @@ -28,14 +28,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When the forward page still exists, the viewer should move to the nearest forward page" ) { _ in // Act - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: Array(identifiers[2...3]), currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - pagingAfterDeletion, + pagingAfterReloading, .init( // Nearest forward page destinationIdentifier: identifiers[4], @@ -48,14 +48,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When all forward pages are deleted, the viewer should move back to the new last page" ) { _ in // Act - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: Array(identifiers[2...]), currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - pagingAfterDeletion, + pagingAfterReloading, .init( // New last page destinationIdentifier: identifiers[1], @@ -68,13 +68,13 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When all pages are deleted, nothing happens" ) { _ in // Act - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: identifiers, currentIdentifier: identifiers[2] ) // Assert - XCTAssertNil(pagingAfterDeletion) + XCTAssertNil(pagingAfterReloading) } } @@ -85,14 +85,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When some non-current pages are deleted" ) { _ in // Act - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: [identifiers[1], identifiers[4]], currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - pagingAfterDeletion, + pagingAfterReloading, .init( // Current page destinationIdentifier: identifiers[2], @@ -105,14 +105,14 @@ final class MediaViewerViewModelTests: XCTestCase { named: "When no pages are deleted" ) { _ in // Act - let pagingAfterDeletion = mediaViewerVM.paging( + let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: [], currentIdentifier: identifiers[2] ) // Assert XCTAssertEqual( - pagingAfterDeletion, + pagingAfterReloading, .init( // Current page destinationIdentifier: identifiers[2], From 45c0a4aa653cf1776bb356318a914e2fb2ef78df Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 04:24:35 +0900 Subject: [PATCH 102/141] update: implement insertMedia(with:) --- Sources/MediaViewer/MediaViewerViewController.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 61590028..838a4cb3 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -503,8 +503,7 @@ open class MediaViewerViewController: UIPageViewController { await pageControlBar.startReloading() - // TODO: Run animations at the same time - await insertMedia(with: insertions.map(\.element)) + insertMedia(with: insertions.map(\.element)) await deleteMedia( with: deletingIdentifiers, visibleVCBeforeDeletion: visibleVCBeforeReloading, @@ -530,9 +529,13 @@ open class MediaViewerViewController: UIPageViewController { private func insertMedia( with insertedIdentifiers: [AnyMediaIdentifier] - ) async { + ) { guard !insertedIdentifiers.isEmpty else { return } - fatalError("Not implemented.") // TODO: implement + pageControlBar.loadItems( + mediaViewerVM.mediaIdentifiers, + expandingItemWith: visiblePageViewController.mediaIdentifier, + animated: true + ) } private func deleteMedia( From 390d77e0dca48b54b072aaccd985cab93b59d400 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 05:40:04 +0900 Subject: [PATCH 103/141] add: CollectionDifference.Change.element --- .../Extensions/CollectionDifference+Extension.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift index a43a0eb8..835785a1 100644 --- a/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift +++ b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift @@ -30,3 +30,13 @@ extension CollectionDifference { return (insertions: insertions, removals: removals) } } + +extension CollectionDifference.Change { + + var element: ChangeElement { + switch self { + case .insert(_, let element, _), .remove(_, let element, _): + return element + } + } +} From c66caf3a1dfdc58acae76d32d8b5d85fcc84493a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 05:44:29 +0900 Subject: [PATCH 104/141] update: improve reloading when current media is not deleted --- .../MediaViewerViewController.swift | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 838a4cb3..e96ef214 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -477,14 +477,22 @@ open class MediaViewerViewController: UIPageViewController { open func reloadMedia() async { let newIdentifiers = fetchMediaIdentifiers() + if newIdentifiers.contains(currentMediaIdentifier) { + // Just reload pageControlBar if current media is not deleted + mediaViewerVM.mediaIdentifiers = newIdentifiers + pageControlBar.loadItems( + mediaViewerVM.mediaIdentifiers, + expandingItemWith: currentMediaIdentifier, + animated: true + ) + return + } let difference = newIdentifiers.difference( from: mediaViewerVM.mediaIdentifiers ) guard !difference.isEmpty else { return } - - let (insertions, removals) = difference.changes - let deletingIdentifiers = removals.map(\.element) + let deletingIdentifiers = difference.removals.map(\.element) let visibleVCBeforeReloading = currentPageViewController @@ -503,7 +511,6 @@ open class MediaViewerViewController: UIPageViewController { await pageControlBar.startReloading() - insertMedia(with: insertions.map(\.element)) await deleteMedia( with: deletingIdentifiers, visibleVCBeforeDeletion: visibleVCBeforeReloading, @@ -527,17 +534,6 @@ open class MediaViewerViewController: UIPageViewController { assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) } - private func insertMedia( - with insertedIdentifiers: [AnyMediaIdentifier] - ) { - guard !insertedIdentifiers.isEmpty else { return } - pageControlBar.loadItems( - mediaViewerVM.mediaIdentifiers, - expandingItemWith: visiblePageViewController.mediaIdentifier, - animated: true - ) - } - private func deleteMedia( with deletedIdentifiers: [AnyMediaIdentifier], visibleVCBeforeDeletion: MediaViewerOnePageViewController, From abfdedafdcf5a1d46abd8848213ba2c91b998984 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 15:10:58 +0900 Subject: [PATCH 105/141] delete: CollectionDifference.changes --- .../CollectionDifference+Extension.swift | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift index 835785a1..e6ed66f7 100644 --- a/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift +++ b/Sources/MediaViewer/Extensions/CollectionDifference+Extension.swift @@ -5,32 +5,6 @@ // Created by Yusaku Nishi on 2023/11/21. // -extension CollectionDifference { - - typealias ChangeAssociatedValues = ( - offset: Int, - element: ChangeElement, - associatedWith: Int? - ) - - var changes: ( - insertions: [ChangeAssociatedValues], - removals: [ChangeAssociatedValues] - ) { - var insertions: [ChangeAssociatedValues] = [] - var removals: [ChangeAssociatedValues] = [] - for change in self { - switch change { - case .insert(let offset, let element, let associatedWith): - insertions.append((offset, element, associatedWith)) - case .remove(let offset, let element, let associatedWith): - removals.append((offset, element, associatedWith)) - } - } - return (insertions: insertions, removals: removals) - } -} - extension CollectionDifference.Change { var element: ChangeElement { From a98af40e9dcc1e40525ca5d64fda91a3a0877269 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 15:26:54 +0900 Subject: [PATCH 106/141] change: run vanish animation even when there is insertion --- .../MediaViewer/MediaViewerViewController.swift | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index e96ef214..b7f4e308 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -477,16 +477,6 @@ open class MediaViewerViewController: UIPageViewController { open func reloadMedia() async { let newIdentifiers = fetchMediaIdentifiers() - if newIdentifiers.contains(currentMediaIdentifier) { - // Just reload pageControlBar if current media is not deleted - mediaViewerVM.mediaIdentifiers = newIdentifiers - pageControlBar.loadItems( - mediaViewerVM.mediaIdentifiers, - expandingItemWith: currentMediaIdentifier, - animated: true - ) - return - } let difference = newIdentifiers.difference( from: mediaViewerVM.mediaIdentifiers @@ -539,8 +529,6 @@ open class MediaViewerViewController: UIPageViewController { visibleVCBeforeDeletion: MediaViewerOnePageViewController, pagingAfterDeletion: MediaViewerViewModel.PagingAfterReloading? ) async { - guard !deletedIdentifiers.isEmpty else { return } - let isVisibleMediaDeleted = deletedIdentifiers.contains( visibleVCBeforeDeletion.mediaIdentifier ) @@ -548,6 +536,11 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Perform vanish animation + /* + * NOTE: + * Play an effect that causes media to disappear. + * This animation will not run if there is no deletion. + */ let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { if isVisibleMediaDeleted { visiblePageView.performVanishAnimationBody() From e64276e5213e31e4fdc2d533ac24b921735647de Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 15:30:38 +0900 Subject: [PATCH 107/141] rename: deleteMedia(with:...) => reloadMedia(deleting:...) --- .../MediaViewerViewController.swift | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b7f4e308..784b8b61 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -501,10 +501,10 @@ open class MediaViewerViewController: UIPageViewController { await pageControlBar.startReloading() - await deleteMedia( - with: deletingIdentifiers, - visibleVCBeforeDeletion: visibleVCBeforeReloading, - pagingAfterDeletion: pagingAfterReloading + await reloadMedia( + deleting: deletingIdentifiers, + visibleVCBeforeReloading: visibleVCBeforeReloading, + pagingAfterReloading: pagingAfterReloading ) /* @@ -524,15 +524,15 @@ open class MediaViewerViewController: UIPageViewController { assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) } - private func deleteMedia( - with deletedIdentifiers: [AnyMediaIdentifier], - visibleVCBeforeDeletion: MediaViewerOnePageViewController, - pagingAfterDeletion: MediaViewerViewModel.PagingAfterReloading? + private func reloadMedia( + deleting deletedIdentifiers: [AnyMediaIdentifier], + visibleVCBeforeReloading: MediaViewerOnePageViewController, + pagingAfterReloading: MediaViewerViewModel.PagingAfterReloading? ) async { let isVisibleMediaDeleted = deletedIdentifiers.contains( - visibleVCBeforeDeletion.mediaIdentifier + visibleVCBeforeReloading.mediaIdentifier ) - let visiblePageView = visibleVCBeforeDeletion.mediaViewerOnePageView + let visiblePageView = visibleVCBeforeReloading.mediaViewerOnePageView // MARK: Perform vanish animation @@ -552,7 +552,7 @@ open class MediaViewerViewController: UIPageViewController { vanishAnimator.startAnimation() // If all media is deleted, close the viewer - guard let pagingAfterDeletion else { + guard let pagingAfterReloading else { assert(mediaViewerVM.mediaIdentifiers.isEmpty) navigationController?.popViewController(animated: true) return @@ -569,11 +569,10 @@ open class MediaViewerViewController: UIPageViewController { return } - guard pagingAfterDeletion.destinationIdentifier == destination.mediaIdentifier else { + guard pagingAfterReloading.destinationIdentifier == destination.mediaIdentifier else { /* * NOTE: - * Do not run finishAnimator because another delete transaction - * will follow. + * Do not run finishAnimator because another reloading will follow. */ return } @@ -585,7 +584,7 @@ open class MediaViewerViewController: UIPageViewController { animated: true ) - if let direction = pagingAfterDeletion.direction { + if let direction = pagingAfterReloading.direction { self.move( to: destination, direction: direction, From b8f99938d41e283e5d241ff1b77cf4f87dd4e9a2 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 17:27:24 +0900 Subject: [PATCH 108/141] add: runningReloadTransactionIDs --- .../MediaViewer/MediaViewerViewController.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 784b8b61..b88e8dd3 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -475,6 +475,8 @@ open class MediaViewerViewController: UIPageViewController { ) } + private var runningReloadTransactionIDs: Set = [] + open func reloadMedia() async { let newIdentifiers = fetchMediaIdentifiers() @@ -499,6 +501,9 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.mediaIdentifiers = newIdentifiers + let transactionID = UUID() + runningReloadTransactionIDs.insert(transactionID) + await pageControlBar.startReloading() await reloadMedia( @@ -507,18 +512,10 @@ open class MediaViewerViewController: UIPageViewController { pagingAfterReloading: pagingAfterReloading ) - /* - * NOTE: - * If another reloading occurs during the reloading, - * destinationPageVCAfterReloading is overwritten with the new value - * and takes a different value from visiblePageViewController. - */ - let isAllReloadingCompleted = visiblePageViewController.mediaIdentifier == destinationPageVCAfterReloading?.mediaIdentifier - if isAllReloadingCompleted { + runningReloadTransactionIDs.remove(transactionID) + if runningReloadTransactionIDs.isEmpty { destinationPageVCAfterReloading = nil pageControlBar.finishReloading() - } else { - // NOTE: Do not finish because there are still reload transactions. } assert(mediaViewerVM.mediaIdentifiers == fetchMediaIdentifiers()) From 1fc8cbc3be80fb54ce75424a97a2b289f032f85b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 18:52:04 +0900 Subject: [PATCH 109/141] docs: comment for reloadMedia() --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b88e8dd3..ccf9ae13 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -477,6 +477,11 @@ open class MediaViewerViewController: UIPageViewController { private var runningReloadTransactionIDs: Set = [] + /// Reloads media. + /// + /// Updates the UI to reflect the state of the data source, animating the UI changes. + /// You need to call this method Immediately after `mediaIdentifiers(for:)` provided by + /// your `MediaViewerDataSource` changes. open func reloadMedia() async { let newIdentifiers = fetchMediaIdentifiers() From 3d67a61e6238a881e05305acc4626057e121a953 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 18:53:23 +0900 Subject: [PATCH 110/141] delete: TODO comment --- Sources/MediaViewer/MediaViewerViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index ccf9ae13..f36726e0 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -493,7 +493,6 @@ open class MediaViewerViewController: UIPageViewController { let visibleVCBeforeReloading = currentPageViewController - // TODO: Consider insertions let pagingAfterReloading = mediaViewerVM.paging( afterDeleting: deletingIdentifiers, currentIdentifier: visibleVCBeforeReloading.mediaIdentifier From 5acc25fcd183609920c1f263e5daab19188dbbe4 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:23:41 +0900 Subject: [PATCH 111/141] change: item in SyncImageVC --- .../Grid/SyncImagesViewController.swift | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 00ed130d..d3a0a1cd 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -10,6 +10,27 @@ import MediaViewer final class SyncImagesViewController: UIViewController { + struct Item: Hashable { + let number: Int + let image: UIImage + + @MainActor + init(number: Int) { + self.number = number + self.image = ImageFactory + .circledText("\(number)", width: 1000) + .withTintColor(.tintColor) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.number == rhs.number + } + + func hash(into hasher: inout Hasher) { + hasher.combine(number) + } + } + private typealias CellRegistration = UICollectionView.CellRegistration< ImageCell, (image: UIImage, contentMode: UIView.ContentMode) @@ -21,14 +42,14 @@ final class SyncImagesViewController: UIViewController { cell.configure(with: item.image, contentMode: item.contentMode) } - private lazy var dataSource = UICollectionViewDiffableDataSource( + private lazy var dataSource = UICollectionViewDiffableDataSource( collectionView: imageGridView.collectionView - ) { [weak self] collectionView, indexPath, image in + ) { [weak self] collectionView, indexPath, item in guard let self else { return nil } return collectionView.dequeueConfiguredReusableCell( using: self.cellRegistration, for: indexPath, - item: (image: image, contentMode: .scaleAspectFill) + item: (image: item.image, contentMode: .scaleAspectFill) ) } @@ -52,10 +73,7 @@ final class SyncImagesViewController: UIViewController { // Subviews var snapshot = dataSource.snapshot() snapshot.appendSections([0]) - snapshot.appendItems((0...20).map { - ImageFactory.circledText("\($0)", width: 1000) - .withTintColor(.tintColor) - }) + snapshot.appendItems((0...20).map(Item.init)) dataSource.apply(snapshot) } } @@ -76,20 +94,20 @@ extension SyncImagesViewController: UICollectionViewDelegate { extension SyncImagesViewController: MediaViewerDataSource { - func mediaIdentifiers(for mediaViewer: MediaViewerViewController) -> [UIImage] { + func mediaIdentifiers(for mediaViewer: MediaViewerViewController) -> [Item] { dataSource.snapshot().itemIdentifiers } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaWith mediaIdentifier: UIImage + mediaWith mediaIdentifier: Item ) -> Media { - .sync(mediaIdentifier) + .sync(mediaIdentifier.image) } func mediaViewer( _ mediaViewer: MediaViewerViewController, - transitionSourceViewForMediaWith mediaIdentifier: UIImage + transitionSourceViewForMediaWith mediaIdentifier: Item ) -> UIView? { let indexPathForCurrentImage = dataSource.indexPath(for: mediaIdentifier)! From 1910307717ada479f95e998cf7a12f2285155a8d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:25:38 +0900 Subject: [PATCH 112/141] add: refresh() --- .../Samples/Grid/SyncImagesViewController.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index d3a0a1cd..c60a4302 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -62,16 +62,19 @@ final class SyncImagesViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + setUpViews() + refresh() } private func setUpViews() { // Navigation navigationItem.title = "Sync Sample" navigationItem.backButtonDisplayMode = .minimal - - // Subviews - var snapshot = dataSource.snapshot() + } + + private func refresh() { + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems((0...20).map(Item.init)) dataSource.apply(snapshot) From 91348bd068478090a4e92bc26fcba9a8760fcc38 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:28:05 +0900 Subject: [PATCH 113/141] add: trash button --- .../Samples/Grid/SyncImagesViewController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index c60a4302..c988cee3 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -79,6 +79,12 @@ final class SyncImagesViewController: UIViewController { snapshot.appendItems((0...20).map(Item.init)) dataSource.apply(snapshot) } + + private func removeItem(_ item: Item) { + var snapshot = dataSource.snapshot() + snapshot.deleteItems([item]) + dataSource.apply(snapshot, animatingDifferences: false) + } } // MARK: - UICollectionViewDelegate - @@ -88,6 +94,12 @@ extension SyncImagesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let image = dataSource.itemIdentifier(for: indexPath)! let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) + mediaViewer.toolbarItems = [ + .flexibleSpace(), + mediaViewer.trashButton { currentMediaIdentifier in + self.removeItem(currentMediaIdentifier) + } + ] navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) } From 5e16980a31f6e00044f26350894069a2564843d4 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:29:38 +0900 Subject: [PATCH 114/141] add: refresh controls --- .../Samples/Grid/SyncImagesViewController.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index c988cee3..a8eb4b8a 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -53,6 +53,13 @@ final class SyncImagesViewController: UIViewController { ) } + private lazy var refreshButton = UIBarButtonItem( + systemItem: .refresh, + primaryAction: .init { [weak self] _ in + Task { self?.refresh() } + } + ) + // MARK: - Lifecycle override func loadView() { @@ -71,6 +78,15 @@ final class SyncImagesViewController: UIViewController { // Navigation navigationItem.title = "Sync Sample" navigationItem.backButtonDisplayMode = .minimal + navigationItem.leftBarButtonItem = refreshButton + + // Subviews + imageGridView.collectionView.refreshControl = .init( + frame: .zero, + primaryAction: .init { [weak self] _ in + self?.refresh() + } + ) } private func refresh() { @@ -78,6 +94,7 @@ final class SyncImagesViewController: UIViewController { snapshot.appendSections([0]) snapshot.appendItems((0...20).map(Item.init)) dataSource.apply(snapshot) + imageGridView.collectionView.refreshControl?.endRefreshing() } private func removeItem(_ item: Item) { From 4ae29d775c9a45fcf70f450d157ce6d534907b5a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:36:45 +0900 Subject: [PATCH 115/141] add: insert new item button --- .../Grid/SyncImagesViewController.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index a8eb4b8a..a6c40636 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -97,6 +97,14 @@ final class SyncImagesViewController: UIViewController { imageGridView.collectionView.refreshControl?.endRefreshing() } + private func insertNewItem(after item: Item) { + var snapshot = dataSource.snapshot() + let maxItem = snapshot.itemIdentifiers.max { $0.number < $1.number }! + let newItem = Item(number: maxItem.number + 1) + snapshot.insertItems([newItem], afterItem: item) + dataSource.apply(snapshot, animatingDifferences: false) + } + private func removeItem(_ item: Item) { var snapshot = dataSource.snapshot() snapshot.deleteItems([item]) @@ -112,6 +120,18 @@ extension SyncImagesViewController: UICollectionViewDelegate { let image = dataSource.itemIdentifier(for: indexPath)! let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) mediaViewer.toolbarItems = [ + .flexibleSpace(), + .init( + systemItem: .add, + primaryAction: .init { [unowned mediaViewer] _ in + self.insertNewItem( + after: mediaViewer.currentMediaIdentifier() + ) + Task { + await mediaViewer.reloadMedia() + } + } + ), .flexibleSpace(), mediaViewer.trashButton { currentMediaIdentifier in self.removeItem(currentMediaIdentifier) From 3d023feeb312933a3c93b0f1391f910134f5bb4e Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:47:47 +0900 Subject: [PATCH 116/141] docs: fix comment --- .../Extensions/MediaViewerViewController+UI.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index d4733e5c..e20cbcdd 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -11,13 +11,13 @@ extension MediaViewerViewController { /// Creates and returns a trash button for deleting media on the current page. /// - /// If you want to provide your custom delete UI, you can build one with `deleteCurrentMedia(after:)` or `deleteMedia(with:after:)` instead. + /// If you want to provide your custom delete UI, you can build one with `reloadMedia()` + /// method instead. /// /// - Note: `deleteAction` must complete deletion until it returns. - /// That means the number of media must be reduced by one after the `deleteAction` is succeeded. - /// If the deletion fails, `deleteAction` must throw an error. + /// If the deletion fails, `deleteAction` can throw an error. /// - Parameter deleteAction: A closure that takes the current media identifier and - /// performs the actual media deletion. + /// performs the media deletion. /// It must complete deletion until it returns. /// - Returns: A trash button for deleting media. public func trashButton( From 3186222cc2bd553332b419b11d7ceeaafebac960 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 20:52:39 +0900 Subject: [PATCH 117/141] change: the trash button to accept non-throwing action --- .../Extensions/MediaViewerViewController+UI.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index e20cbcdd..b7bc9171 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -14,21 +14,19 @@ extension MediaViewerViewController { /// If you want to provide your custom delete UI, you can build one with `reloadMedia()` /// method instead. /// - /// - Note: `deleteAction` must complete deletion until it returns. - /// If the deletion fails, `deleteAction` can throw an error. /// - Parameter deleteAction: A closure that takes the current media identifier and /// performs the media deletion. - /// It must complete deletion until it returns. + /// - Note: `deleteAction` must complete deletion until it returns. /// - Returns: A trash button for deleting media. public func trashButton( deleteAction: @escaping ( _ currentMediaIdentifier: MediaIdentifier - ) async throws -> Void + ) async -> Void ) -> UIBarButtonItem where MediaIdentifier: Hashable { .init(systemItem: .trash, primaryAction: .init { [weak self] action in guard let self else { return } Task { - try await deleteAction(self.currentMediaIdentifier()) + await deleteAction(self.currentMediaIdentifier()) await self.reloadMedia() } }) From fcba92809d58c4e1a9326fad99c4736b51922e08 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Sun, 26 Nov 2023 21:31:38 +0900 Subject: [PATCH 118/141] update: trashButton.deleteAction to take UIBarButtonItem --- .../Samples/Grid/AsyncImagesViewController.swift | 2 +- .../Samples/Grid/SyncImagesViewController.swift | 2 +- .../Extensions/MediaViewerViewController+UI.swift | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index e1ef55fa..038852a9 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -182,7 +182,7 @@ extension AsyncImagesViewController: UICollectionViewDelegate { .flexibleSpace(), .init(image: .init(systemName: "info.circle")), .flexibleSpace(), - mediaViewer.trashButton { currentMediaIdentifier in + mediaViewer.trashButton { _, currentMediaIdentifier in await self.removeAsset(currentMediaIdentifier) } ] diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index a6c40636..dc990a15 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -133,7 +133,7 @@ extension SyncImagesViewController: UICollectionViewDelegate { } ), .flexibleSpace(), - mediaViewer.trashButton { currentMediaIdentifier in + mediaViewer.trashButton { _, currentMediaIdentifier in self.removeItem(currentMediaIdentifier) } ] diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index b7bc9171..529cefa1 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -20,15 +20,18 @@ extension MediaViewerViewController { /// - Returns: A trash button for deleting media. public func trashButton( deleteAction: @escaping ( + UIBarButtonItem, _ currentMediaIdentifier: MediaIdentifier ) async -> Void ) -> UIBarButtonItem where MediaIdentifier: Hashable { - .init(systemItem: .trash, primaryAction: .init { [weak self] action in + let button = UIBarButtonItem(systemItem: .trash) + button.primaryAction = .init { [weak self] action in guard let self else { return } Task { - await deleteAction(self.currentMediaIdentifier()) + await deleteAction(button, self.currentMediaIdentifier()) await self.reloadMedia() } - }) + } + return button } } From c8ced86bdf60b1e0a962c8a201034836ca05ba7f Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 01:58:31 +0900 Subject: [PATCH 119/141] update: show confirmation --- .../Grid/AsyncImagesViewController.swift | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 038852a9..7db9710f 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -182,13 +182,47 @@ extension AsyncImagesViewController: UICollectionViewDelegate { .flexibleSpace(), .init(image: .init(systemName: "info.circle")), .flexibleSpace(), - mediaViewer.trashButton { _, currentMediaIdentifier in - await self.removeAsset(currentMediaIdentifier) + mediaViewer.trashButton { button, currentAsset in + try? await self.showConfirmationForPhotoRemoval( + from: button, + on: mediaViewer, + removingAsset: currentAsset + ) } ] navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) } + + private func showConfirmationForPhotoRemoval( + from button: UIBarButtonItem, + on mediaViewer: MediaViewerViewController, + removingAsset: PHAsset + ) async throws { + try await withCheckedThrowingContinuation { continuation in + let actionSheet = UIAlertController( + title: "Do you simulate photo removal?", + message: "The photo won't actually be deleted.", + preferredStyle: .actionSheet + ) + let removeAction = UIAlertAction( + title: "Simulate", + style: .default + ) { _ in + Task { + await self.removeAsset(removingAsset) + continuation.resume() + } + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in + continuation.resume(throwing: CancellationError()) + } + actionSheet.addAction(removeAction) + actionSheet.addAction(cancelAction) + actionSheet.popoverPresentationController?.sourceItem = button + mediaViewer.present(actionSheet, animated: true) + } + } } // MARK: - MediaViewerDataSource - From d703ac41d1fd30f74f928abcbc5663d82e52c577 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 02:58:18 +0900 Subject: [PATCH 120/141] fix: circuler reference --- .../Samples/Grid/AsyncImagesViewController.swift | 2 +- .../Samples/Grid/SyncImagesViewController.swift | 2 +- .../Extensions/MediaViewerViewController+UI.swift | 14 ++++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift index 7db9710f..05c828bb 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/AsyncImagesViewController.swift @@ -182,7 +182,7 @@ extension AsyncImagesViewController: UICollectionViewDelegate { .flexibleSpace(), .init(image: .init(systemName: "info.circle")), .flexibleSpace(), - mediaViewer.trashButton { button, currentAsset in + mediaViewer.trashButton { mediaViewer, button, currentAsset in try? await self.showConfirmationForPhotoRemoval( from: button, on: mediaViewer, diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index dc990a15..3779c2ee 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -133,7 +133,7 @@ extension SyncImagesViewController: UICollectionViewDelegate { } ), .flexibleSpace(), - mediaViewer.trashButton { _, currentMediaIdentifier in + mediaViewer.trashButton { _, _, currentMediaIdentifier in self.removeItem(currentMediaIdentifier) } ] diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index 529cefa1..c9772130 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -14,13 +14,15 @@ extension MediaViewerViewController { /// If you want to provide your custom delete UI, you can build one with `reloadMedia()` /// method instead. /// - /// - Parameter deleteAction: A closure that takes the current media identifier and - /// performs the media deletion. + /// - Parameters: + /// - deleteAction: A closure that performs the media deletion. + /// It takes the viewer, button itself and the current media identifier. /// - Note: `deleteAction` must complete deletion until it returns. /// - Returns: A trash button for deleting media. public func trashButton( deleteAction: @escaping ( - UIBarButtonItem, + _ mediaViewer: MediaViewerViewController, + _ trashButton: UIBarButtonItem, _ currentMediaIdentifier: MediaIdentifier ) async -> Void ) -> UIBarButtonItem where MediaIdentifier: Hashable { @@ -28,7 +30,11 @@ extension MediaViewerViewController { button.primaryAction = .init { [weak self] action in guard let self else { return } Task { - await deleteAction(button, self.currentMediaIdentifier()) + await deleteAction( + self, + button, + self.currentMediaIdentifier() + ) await self.reloadMedia() } } From 18b2edd05c634972a5d202901ab54b8968d17cb4 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 22:32:19 +0900 Subject: [PATCH 121/141] rename: delete => vanish --- .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 2 +- .../PageControlBar/PageControlBarThumbnailCell.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 07e07934..eaee5856 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -423,7 +423,7 @@ extension MediaViewerPageControlBar { assert(state == .reloading) for identifier in identifiers { - cell(for: identifier)?.performDeleteAnimationBody() + cell(for: identifier)?.performVanishAnimationBody() } } } diff --git a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift index aaa992d9..00448f38 100644 --- a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift +++ b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift @@ -65,7 +65,7 @@ final class PageControlBarThumbnailCell: UICollectionViewCell { imageLoadingTask = imageView.load(imageSource) } - func performDeleteAnimationBody() { + func performVanishAnimationBody() { // NOTE: These changes are reset in prepareForReuse(). transform = transform.scaledBy(x: 0.5, y: 0.5) alpha = 0 From 053d97ef9e9898ae2d9a92b70691f2356ae789c7 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 22:34:23 +0900 Subject: [PATCH 122/141] chore: add TODO comments --- .../MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift | 1 + .../MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift index 542278fc..fe4ecd37 100644 --- a/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift +++ b/Sources/MediaViewer/MediaViewerOnePage/MediaViewerOnePageView.swift @@ -161,6 +161,7 @@ final class MediaViewerOnePageView: UIView { } func performVanishAnimationBody() { + // TODO: Apply the same blur effect as standard imageView.transform = imageView.transform.scaledBy(x: 0.5, y: 0.5) imageView.alpha = 0 } diff --git a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift index 00448f38..1e07d0ee 100644 --- a/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift +++ b/Sources/MediaViewer/PageControlBar/PageControlBarThumbnailCell.swift @@ -66,6 +66,7 @@ final class PageControlBarThumbnailCell: UICollectionViewCell { } func performVanishAnimationBody() { + // TODO: Apply the same blur effect as standard // NOTE: These changes are reset in prepareForReuse(). transform = transform.scaledBy(x: 0.5, y: 0.5) alpha = 0 From 02056f012d01ad1bfccdec5c6b4bff1e6d630b07 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 23:02:26 +0900 Subject: [PATCH 123/141] add: completions --- .../MediaViewerPageControlBar.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index eaee5856..6d1b00f4 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -218,10 +218,12 @@ final class MediaViewerPageControlBar: UIView { /// - identifiers: Identifiers for media to load. /// - expandingIdentifier: An identifier for media to expand after the loading. /// - animated: Whether to animate the loading. + /// - completion: A closure to execute when the loading completes. func loadItems( _ identifiers: [AnyMediaIdentifier], expandingItemWith expandingIdentifier: AnyMediaIdentifier, - animated: Bool + animated: Bool, + completion: (() -> Void)? = nil ) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) @@ -229,6 +231,7 @@ final class MediaViewerPageControlBar: UIView { diffableDataSource.apply(snapshot, animatingDifferences: animated) guard let indexPath = diffableDataSource.indexPath(for: expandingIdentifier) else { + completion?() return } _pageDidChange.send((page: indexPath.item, reason: .load)) @@ -239,7 +242,9 @@ final class MediaViewerPageControlBar: UIView { widthToHeightOfThumbnailWith: expandingIdentifier ), animated: animated - ) + ) { _ in + completion?() + } } private func page(with identifier: AnyMediaIdentifier) -> Int? { @@ -265,7 +270,8 @@ final class MediaViewerPageControlBar: UIView { private func updateLayout( expandingItemAt indexPath: IndexPath?, expandingThumbnailWidthToHeight: CGFloat? = nil, - animated: Bool + animated: Bool, + completion: ((Bool) -> Void)? = nil ) { let style: MediaViewerPageControlBarLayout.Style if let indexPath { @@ -277,7 +283,11 @@ final class MediaViewerPageControlBar: UIView { style = .collapsed } let layout = MediaViewerPageControlBarLayout(style: style) - collectionView.setCollectionViewLayout(layout, animated: animated) + collectionView.setCollectionViewLayout( + layout, + animated: animated, + completion: completion + ) } /// Expand an item and scroll there. From 0232b886124bb1bccb7509561805a2b5f010d102 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 23:10:33 +0900 Subject: [PATCH 124/141] change: AnyMediaIdentifier.init(rawValue:) => .init(_:) and rawValue => base --- .../MediaViewer/MediaViewerDataSource.swift | 8 ++++---- Sources/MediaViewer/MediaViewerDelegate.swift | 2 +- .../MediaViewerViewController.swift | 19 ++++++++++--------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index 9d0a8700..3cbaee63 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -151,7 +151,7 @@ extension MediaViewerDataSource { ) -> Media { self.mediaViewer( mediaViewer, - mediaWith: mediaIdentifier.rawValue as! MediaIdentifier + mediaWith: mediaIdentifier.base as! MediaIdentifier ) } @@ -161,7 +161,7 @@ extension MediaViewerDataSource { ) -> CGFloat? { self.mediaViewer( mediaViewer, - widthToHeightOfMediaWith: mediaIdentifier.rawValue as! MediaIdentifier + widthToHeightOfMediaWith: mediaIdentifier.base as! MediaIdentifier ) } @@ -172,7 +172,7 @@ extension MediaViewerDataSource { ) -> Source { self.mediaViewer( mediaViewer, - pageThumbnailForMediaWith: mediaIdentifier.rawValue as! MediaIdentifier, + pageThumbnailForMediaWith: mediaIdentifier.base as! MediaIdentifier, filling: preferredThumbnailSize ) } @@ -183,7 +183,7 @@ extension MediaViewerDataSource { ) -> UIView? { self.mediaViewer( mediaViewer, - transitionSourceViewForMediaWith: mediaIdentifier.rawValue as! MediaIdentifier + transitionSourceViewForMediaWith: mediaIdentifier.base as! MediaIdentifier ) } } diff --git a/Sources/MediaViewer/MediaViewerDelegate.swift b/Sources/MediaViewer/MediaViewerDelegate.swift index 7a44ac7d..f9bb3125 100644 --- a/Sources/MediaViewer/MediaViewerDelegate.swift +++ b/Sources/MediaViewer/MediaViewerDelegate.swift @@ -65,7 +65,7 @@ extension MediaViewerDelegate { ) { self.mediaViewer( mediaViewer, - didMoveToMediaWith: mediaIdentifier.rawValue as! MediaIdentifier + didMoveToMediaWith: mediaIdentifier.base as! MediaIdentifier ) } } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index f36726e0..00345533 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -10,12 +10,13 @@ import Combine /// A type-erased media identifier. struct AnyMediaIdentifier: Hashable { - let rawValue: AnyHashable + + let base: AnyHashable init( - rawValue: MediaIdentifier + _ base: MediaIdentifier ) where MediaIdentifier: Hashable { - self.rawValue = rawValue + self.base = base } } @@ -71,10 +72,10 @@ open class MediaViewerViewController: UIPageViewController { public func currentMediaIdentifier( as identifierType: MediaIdentifier.Type = MediaIdentifier.self ) -> MediaIdentifier { - let rawIdentifier = currentMediaIdentifier.rawValue - guard let identifier = rawIdentifier as? MediaIdentifier else { + let baseIdentifier = currentMediaIdentifier.base + guard let identifier = baseIdentifier as? MediaIdentifier else { preconditionFailure( - "The type of media identifier is \(type(of: rawIdentifier.base)), not \(identifierType)." + "The type of media identifier is \(type(of: baseIdentifier.base)), not \(identifierType)." ) } return identifier @@ -192,7 +193,7 @@ open class MediaViewerViewController: UIPageViewController { mediaViewerVM.mediaIdentifiers = identifiers.map(AnyMediaIdentifier.init) let mediaViewerPage = makeMediaViewerPage( - with: AnyMediaIdentifier(rawValue: mediaIdentifier) + with: AnyMediaIdentifier(mediaIdentifier) ) setViewControllers([mediaViewerPage], direction: .forward, animated: false) @@ -424,7 +425,7 @@ open class MediaViewerViewController: UIPageViewController { func fetchMediaIdentifiers() -> [AnyMediaIdentifier] { mediaViewerDataSource .mediaIdentifiers(for: self) - .map { AnyMediaIdentifier(rawValue: $0) } + .map { AnyMediaIdentifier($0) } } /// Move to media with the specified identifier. @@ -439,7 +440,7 @@ open class MediaViewerViewController: UIPageViewController { completion: ((Bool) -> Void)? = nil ) where MediaIdentifier: Hashable { self.move( - toMediaWith: AnyMediaIdentifier(rawValue: identifier), + toMediaWith: AnyMediaIdentifier(identifier), animated: animated, completion: completion ) From acbd646c9eea7001c22752a8d55f5dba75e05fb0 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 23:23:56 +0900 Subject: [PATCH 125/141] change: prevent AnyMediaIdentifier from being duplicated --- Sources/MediaViewer/MediaViewerViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 00345533..b4d43ead 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -16,6 +16,11 @@ struct AnyMediaIdentifier: Hashable { init( _ base: MediaIdentifier ) where MediaIdentifier: Hashable { + if let base = base as? AnyMediaIdentifier { + assertionFailure("The base is already type-erased.") + self = base + return + } self.base = base } } From 2be32077a391acae9b3a535a6f445b0a485eb72b Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 23:33:58 +0900 Subject: [PATCH 126/141] add: AnyMediaIdentifier.as(_:) --- .../MediaViewer/MediaViewerDataSource.swift | 8 ++++---- Sources/MediaViewer/MediaViewerDelegate.swift | 2 +- .../MediaViewerViewController.swift | 19 ++++++++++++------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerDataSource.swift b/Sources/MediaViewer/MediaViewerDataSource.swift index 3cbaee63..a591ea5d 100644 --- a/Sources/MediaViewer/MediaViewerDataSource.swift +++ b/Sources/MediaViewer/MediaViewerDataSource.swift @@ -151,7 +151,7 @@ extension MediaViewerDataSource { ) -> Media { self.mediaViewer( mediaViewer, - mediaWith: mediaIdentifier.base as! MediaIdentifier + mediaWith: mediaIdentifier.as(MediaIdentifier.self) ) } @@ -161,7 +161,7 @@ extension MediaViewerDataSource { ) -> CGFloat? { self.mediaViewer( mediaViewer, - widthToHeightOfMediaWith: mediaIdentifier.base as! MediaIdentifier + widthToHeightOfMediaWith: mediaIdentifier.as(MediaIdentifier.self) ) } @@ -172,7 +172,7 @@ extension MediaViewerDataSource { ) -> Source { self.mediaViewer( mediaViewer, - pageThumbnailForMediaWith: mediaIdentifier.base as! MediaIdentifier, + pageThumbnailForMediaWith: mediaIdentifier.as(MediaIdentifier.self), filling: preferredThumbnailSize ) } @@ -183,7 +183,7 @@ extension MediaViewerDataSource { ) -> UIView? { self.mediaViewer( mediaViewer, - transitionSourceViewForMediaWith: mediaIdentifier.base as! MediaIdentifier + transitionSourceViewForMediaWith: mediaIdentifier.as(MediaIdentifier.self) ) } } diff --git a/Sources/MediaViewer/MediaViewerDelegate.swift b/Sources/MediaViewer/MediaViewerDelegate.swift index f9bb3125..946e6c10 100644 --- a/Sources/MediaViewer/MediaViewerDelegate.swift +++ b/Sources/MediaViewer/MediaViewerDelegate.swift @@ -65,7 +65,7 @@ extension MediaViewerDelegate { ) { self.mediaViewer( mediaViewer, - didMoveToMediaWith: mediaIdentifier.base as! MediaIdentifier + didMoveToMediaWith: mediaIdentifier.as(MediaIdentifier.self) ) } } diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index b4d43ead..ef4e2029 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -23,6 +23,17 @@ struct AnyMediaIdentifier: Hashable { } self.base = base } + + func `as`( + _ identifierType: MediaIdentifier.Type + ) -> MediaIdentifier { + guard let identifier = base as? MediaIdentifier else { + preconditionFailure( + "The type of media identifier is \(type(of: base.base)), not \(identifierType)." + ) + } + return identifier + } } /// An media viewer. @@ -77,13 +88,7 @@ open class MediaViewerViewController: UIPageViewController { public func currentMediaIdentifier( as identifierType: MediaIdentifier.Type = MediaIdentifier.self ) -> MediaIdentifier { - let baseIdentifier = currentMediaIdentifier.base - guard let identifier = baseIdentifier as? MediaIdentifier else { - preconditionFailure( - "The type of media identifier is \(type(of: baseIdentifier.base)), not \(identifierType)." - ) - } - return identifier + currentMediaIdentifier.as(MediaIdentifier.self) } var currentMediaIdentifier: AnyMediaIdentifier { From 9df1b81b3877142d6feb24b89d85b777e3fdd0ab Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Mon, 27 Nov 2023 23:35:59 +0900 Subject: [PATCH 127/141] chore: divide file --- Sources/MediaViewer/AnyMediaIdentifier.swift | 34 +++++++++++++++++++ .../MediaViewerViewController.swift | 28 --------------- 2 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 Sources/MediaViewer/AnyMediaIdentifier.swift diff --git a/Sources/MediaViewer/AnyMediaIdentifier.swift b/Sources/MediaViewer/AnyMediaIdentifier.swift new file mode 100644 index 00000000..49595614 --- /dev/null +++ b/Sources/MediaViewer/AnyMediaIdentifier.swift @@ -0,0 +1,34 @@ +// +// AnyMediaIdentifier.swift +// +// +// Created by Yusaku Nishi on 2023/11/27. +// + +/// A type-erased media identifier. +struct AnyMediaIdentifier: Hashable { + + let base: AnyHashable + + init( + _ base: MediaIdentifier + ) where MediaIdentifier: Hashable { + if let base = base as? AnyMediaIdentifier { + assertionFailure("The base is already type-erased.") + self = base + return + } + self.base = base + } + + func `as`( + _ identifierType: MediaIdentifier.Type + ) -> MediaIdentifier { + guard let identifier = base as? MediaIdentifier else { + preconditionFailure( + "The type of media identifier is \(type(of: base.base)), not \(identifierType)." + ) + } + return identifier + } +} diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index ef4e2029..cce7caf1 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -8,34 +8,6 @@ import UIKit import Combine -/// A type-erased media identifier. -struct AnyMediaIdentifier: Hashable { - - let base: AnyHashable - - init( - _ base: MediaIdentifier - ) where MediaIdentifier: Hashable { - if let base = base as? AnyMediaIdentifier { - assertionFailure("The base is already type-erased.") - self = base - return - } - self.base = base - } - - func `as`( - _ identifierType: MediaIdentifier.Type - ) -> MediaIdentifier { - guard let identifier = base as? MediaIdentifier else { - preconditionFailure( - "The type of media identifier is \(type(of: base.base)), not \(identifierType)." - ) - } - return identifier - } -} - /// An media viewer. /// /// It is recommended to set your `MediaViewerViewController` instance to `navigationController?.delegate` to enable smooth transition animation. From d89a0194234b9f95126ca7f071d7fdda87773379 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 02:01:54 +0900 Subject: [PATCH 128/141] change: allow AnyMediaIdentifier(AnyMediaIdentifier(...)) --- Sources/MediaViewer/AnyMediaIdentifier.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MediaViewer/AnyMediaIdentifier.swift b/Sources/MediaViewer/AnyMediaIdentifier.swift index 49595614..0d946f7e 100644 --- a/Sources/MediaViewer/AnyMediaIdentifier.swift +++ b/Sources/MediaViewer/AnyMediaIdentifier.swift @@ -14,7 +14,7 @@ struct AnyMediaIdentifier: Hashable { _ base: MediaIdentifier ) where MediaIdentifier: Hashable { if let base = base as? AnyMediaIdentifier { - assertionFailure("The base is already type-erased.") + // Already type-erased self = base return } From 94ac9d555383dc4759cff1e21df830854d5cb998 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 02:07:36 +0900 Subject: [PATCH 129/141] add: AnyMediaIdentifierTests --- .../AnyMediaIdentifierTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Tests/MediaViewerTests/AnyMediaIdentifierTests.swift diff --git a/Tests/MediaViewerTests/AnyMediaIdentifierTests.swift b/Tests/MediaViewerTests/AnyMediaIdentifierTests.swift new file mode 100644 index 00000000..bf0e8bbf --- /dev/null +++ b/Tests/MediaViewerTests/AnyMediaIdentifierTests.swift @@ -0,0 +1,22 @@ +// +// AnyMediaIdentifierTests.swift +// +// +// Created by Yusaku Nishi on 2023/11/28. +// + +import XCTest +@testable import MediaViewer + +final class AnyMediaIdentifierTests: XCTestCase { + + func testInit() { + let identifier = AnyMediaIdentifier(1) + XCTAssertEqual(AnyMediaIdentifier(identifier), identifier) + } + + func testCasting() { + let identifier = AnyMediaIdentifier("some identifier") + XCTAssertEqual(identifier.as(String.self), "some identifier") + } +} From fe135f5e8477cf60c74431820d7e84890a50c40d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 02:11:40 +0900 Subject: [PATCH 130/141] delete: move(toMediaWith:animated:completion:) for type-erased identifier --- Sources/MediaViewer/MediaViewerViewController.swift | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index cce7caf1..06d620ce 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -421,18 +421,7 @@ open class MediaViewerViewController: UIPageViewController { animated: Bool, completion: ((Bool) -> Void)? = nil ) where MediaIdentifier: Hashable { - self.move( - toMediaWith: AnyMediaIdentifier(identifier), - animated: animated, - completion: completion - ) - } - - private func move( - toMediaWith identifier: AnyMediaIdentifier, - animated: Bool, - completion: ((Bool) -> Void)? = nil - ) { + let identifier = AnyMediaIdentifier(identifier) move( to: makeMediaViewerPage(with: identifier), direction: mediaViewerVM.moveDirection( From a8845bd97fe1c48b9206c8ebc8414e304563b382 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 21:19:03 +0900 Subject: [PATCH 131/141] change: unowned => weak --- .../Samples/Grid/SyncImagesViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 3779c2ee..379d8e74 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -123,7 +123,8 @@ extension SyncImagesViewController: UICollectionViewDelegate { .flexibleSpace(), .init( systemItem: .add, - primaryAction: .init { [unowned mediaViewer] _ in + primaryAction: .init { [weak mediaViewer] _ in + guard let mediaViewer else { return } self.insertNewItem( after: mediaViewer.currentMediaIdentifier() ) From 76addb5c43a28a369f72a423c81ed806c6c260ef Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 21:25:35 +0900 Subject: [PATCH 132/141] add: refresh button on MediaViewer in sync demo --- .../Samples/Grid/SyncImagesViewController.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 379d8e74..e0aa1e2d 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -119,7 +119,19 @@ extension SyncImagesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let image = dataSource.itemIdentifier(for: indexPath)! let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) + + // NOTE: `weak mediaViewer` captures are needed. mediaViewer.toolbarItems = [ + .init( + systemItem: .refresh, + primaryAction: .init { [weak mediaViewer] _ in + guard let mediaViewer else { return } + self.refresh() + Task { + await mediaViewer.reloadMedia() + } + } + ), .flexibleSpace(), .init( systemItem: .add, @@ -138,6 +150,7 @@ extension SyncImagesViewController: UICollectionViewDelegate { self.removeItem(currentMediaIdentifier) } ] + navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) } From 53de06c6e7b98514814f711c8736d88a2eeb7896 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 21:35:21 +0900 Subject: [PATCH 133/141] chore: refactor --- .../Samples/Grid/SyncImagesViewController.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index e0aa1e2d..19732983 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -119,7 +119,17 @@ extension SyncImagesViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let image = dataSource.itemIdentifier(for: indexPath)! let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) - + setUpToolbarItems(of: mediaViewer) + navigationController?.delegate = mediaViewer + navigationController?.pushViewController(mediaViewer, animated: true) + } + + /* + * NOTE: + * Here the instance of MediaViewerViewController is customized directly, + * but you can subclass MediaViewerViewController instead. + */ + private func setUpToolbarItems(of mediaViewer: MediaViewerViewController) { // NOTE: `weak mediaViewer` captures are needed. mediaViewer.toolbarItems = [ .init( @@ -150,9 +160,6 @@ extension SyncImagesViewController: UICollectionViewDelegate { self.removeItem(currentMediaIdentifier) } ] - - navigationController?.delegate = mediaViewer - navigationController?.pushViewController(mediaViewer, animated: true) } } From b2f52588c694409a21f136490bfad7f01c312e7a Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Tue, 28 Nov 2023 21:37:54 +0900 Subject: [PATCH 134/141] docs: improve doc comment --- .../MediaViewer/MediaViewerViewController.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index 06d620ce..bf96c06a 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -10,15 +10,16 @@ import Combine /// An media viewer. /// -/// It is recommended to set your `MediaViewerViewController` instance to `navigationController?.delegate` to enable smooth transition animation. +/// It is recommended to set your `MediaViewerViewController` instance to +/// `navigationController?.delegate` to enable smooth transition animation. /// /// ```swift -/// let mediaViewer = MediaViewerViewController(page: 0, dataSource: self) +/// let mediaViewer = MediaViewerViewController(opening: 0, dataSource: self) /// navigationController?.delegate = mediaViewer /// navigationController?.pushViewController(mediaViewer, animated: true) /// ``` /// -/// You can show toolbar items by setting `toolbarItems` property on the media viewer instance. +/// To show toolbar items in the media viewer, use `toolbarItems` property on the viewer instance. /// /// ```swift /// mediaViewer.toolbarItems = [ @@ -26,8 +27,12 @@ import Combine /// ] /// ``` /// -/// - Note: `MediaViewerViewController` must be used in `UINavigationController`. -/// It is NOT allowed to change `dataSource` and `delegate` properties of ``UIPageViewController``. +/// You can subclass `MediaViewerViewController` and customize it. +/// +/// - Note: `MediaViewerViewController` must be embedded in +/// `UINavigationController`. +/// - Note: It is NOT allowed to change `dataSource` and `delegate` properties +/// of ``UIPageViewController``. open class MediaViewerViewController: UIPageViewController { private var cancellables: Set = [] From a1b9119b4244d5d6a56d736cef0f83effc207f06 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 29 Nov 2023 22:18:58 +0900 Subject: [PATCH 135/141] chore: fix comment --- .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 6d1b00f4..237c7f0f 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -405,7 +405,7 @@ final class MediaViewerPageControlBar: UIView { } } -// MARK: - Deletion - +// MARK: - Reloading - extension MediaViewerPageControlBar { From 13565e8616d6838c962f85013f3fe51eea84f9d0 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 29 Nov 2023 22:22:07 +0900 Subject: [PATCH 136/141] chore: add comments --- Sources/MediaViewer/MediaViewerViewModel.swift | 1 + .../MediaViewer/PageControlBar/MediaViewerPageControlBar.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/MediaViewer/MediaViewerViewModel.swift b/Sources/MediaViewer/MediaViewerViewModel.swift index 403127f9..56d98498 100644 --- a/Sources/MediaViewer/MediaViewerViewModel.swift +++ b/Sources/MediaViewer/MediaViewerViewModel.swift @@ -83,6 +83,7 @@ extension MediaViewerViewModel { let backwardIdentifiers = splitIdentifiers[0] let forwardIdentifiers = splitIdentifiers[1] + // TODO: Prefer the recent paging direction if let nearestForward = forwardIdentifiers.first(where: { !deletingIdentifiers.contains($0) }) { diff --git a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift index 237c7f0f..bdfad430 100644 --- a/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift +++ b/Sources/MediaViewer/PageControlBar/MediaViewerPageControlBar.swift @@ -517,6 +517,7 @@ extension MediaViewerPageControlBar: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) + // FIXME: Allow selection during the reloading guard state != .reloading else { return } if case .normal(let barLayout) = layout, From 6182ef692b6d986917ffdf415b808eb69bd9684f Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 29 Nov 2023 23:01:43 +0900 Subject: [PATCH 137/141] update: allow pop during the reloading --- .../MediaViewerViewController.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index bf96c06a..d518e6a5 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -609,19 +609,29 @@ open class MediaViewerViewController: UIPageViewController { // MARK: - Actions + private var canTransitionWithSourceView: Bool { + switch pageControlBar.state { + case .collapsing, .collapsed, .expanding, .expanded, .transitioningInteractively: + return true + case .reloading: + /* + * FIXME: A transition with the source view does not work correctly during reloading + */ + return false + } + } + @objc private func panned(recognizer: UIPanGestureRecognizer) { - guard pageControlBar.state == .expanded else { - recognizer.state = .failed - return - } if recognizer.state == .began { // Start the interactive pop transition - let sourceView = mediaViewerDataSource.mediaViewer( - self, - transitionSourceViewForMediaWith: currentMediaIdentifier - ) - interactivePopTransition = .init(sourceView: sourceView) + if canTransitionWithSourceView { + let sourceView = mediaViewerDataSource.mediaViewer( + self, + transitionSourceViewForMediaWith: currentMediaIdentifier + ) + interactivePopTransition = .init(sourceView: sourceView) + } /* * [Workaround] @@ -802,10 +812,15 @@ extension MediaViewerViewController: UINavigationControllerDelegate { ) } - let sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource.mediaViewer( - self, - transitionSourceViewForMediaWith: currentMediaIdentifier - ) + let sourceView: UIView? + if canTransitionWithSourceView { + sourceView = interactivePopTransition?.sourceView ?? mediaViewerDataSource.mediaViewer( + self, + transitionSourceViewForMediaWith: currentMediaIdentifier + ) + } else { + sourceView = nil + } return MediaViewerTransition( operation: operation, sourceView: sourceView, From ae9c87014f2e0aea3589a268f9524a3bb56a45b5 Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 29 Nov 2023 23:32:00 +0900 Subject: [PATCH 138/141] add: argument `animated` to reloadMedia() --- .../Grid/SyncImagesViewController.swift | 4 +- .../MediaViewerViewController+UI.swift | 4 +- .../MediaViewerViewController.swift | 74 ++++++++++++------- 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift index 19732983..bc1f124f 100644 --- a/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift +++ b/Demo/MediaViewerDemo/Samples/Grid/SyncImagesViewController.swift @@ -138,7 +138,7 @@ extension SyncImagesViewController: UICollectionViewDelegate { guard let mediaViewer else { return } self.refresh() Task { - await mediaViewer.reloadMedia() + await mediaViewer.reloadMedia(animated: true) } } ), @@ -151,7 +151,7 @@ extension SyncImagesViewController: UICollectionViewDelegate { after: mediaViewer.currentMediaIdentifier() ) Task { - await mediaViewer.reloadMedia() + await mediaViewer.reloadMedia(animated: true) } } ), diff --git a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift index c9772130..ac4344fb 100644 --- a/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift +++ b/Sources/MediaViewer/Extensions/MediaViewerViewController+UI.swift @@ -15,11 +15,13 @@ extension MediaViewerViewController { /// method instead. /// /// - Parameters: + /// - animatingDeletion: Whether to animate the media deletion. /// - deleteAction: A closure that performs the media deletion. /// It takes the viewer, button itself and the current media identifier. /// - Note: `deleteAction` must complete deletion until it returns. /// - Returns: A trash button for deleting media. public func trashButton( + animatingDeletion: Bool = true, deleteAction: @escaping ( _ mediaViewer: MediaViewerViewController, _ trashButton: UIBarButtonItem, @@ -35,7 +37,7 @@ extension MediaViewerViewController { button, self.currentMediaIdentifier() ) - await self.reloadMedia() + await self.reloadMedia(animated: animatingDeletion) } } return button diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index d518e6a5..c3bdc9a2 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -456,10 +456,12 @@ open class MediaViewerViewController: UIPageViewController { /// Reloads media. /// - /// Updates the UI to reflect the state of the data source, animating the UI changes. + /// Updates the UI to reflect the state of the data source, optionally animating the UI changes. /// You need to call this method Immediately after `mediaIdentifiers(for:)` provided by /// your `MediaViewerDataSource` changes. - open func reloadMedia() async { + /// + /// - Parameter animated: Whether to animate the reloading. + open func reloadMedia(animated: Bool) async { let newIdentifiers = fetchMediaIdentifiers() let difference = newIdentifiers.difference( @@ -490,7 +492,8 @@ open class MediaViewerViewController: UIPageViewController { await reloadMedia( deleting: deletingIdentifiers, visibleVCBeforeReloading: visibleVCBeforeReloading, - pagingAfterReloading: pagingAfterReloading + pagingAfterReloading: pagingAfterReloading, + animated: animated ) runningReloadTransactionIDs.remove(transactionID) @@ -505,7 +508,8 @@ open class MediaViewerViewController: UIPageViewController { private func reloadMedia( deleting deletedIdentifiers: [AnyMediaIdentifier], visibleVCBeforeReloading: MediaViewerOnePageViewController, - pagingAfterReloading: MediaViewerViewModel.PagingAfterReloading? + pagingAfterReloading: MediaViewerViewModel.PagingAfterReloading?, + animated: Bool ) async { let isVisibleMediaDeleted = deletedIdentifiers.contains( visibleVCBeforeReloading.mediaIdentifier @@ -514,31 +518,37 @@ open class MediaViewerViewController: UIPageViewController { // MARK: Perform vanish animation - /* - * NOTE: - * Play an effect that causes media to disappear. - * This animation will not run if there is no deletion. - */ - let vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { - if isVisibleMediaDeleted { - visiblePageView.performVanishAnimationBody() + let vanishAnimator: UIViewPropertyAnimator? + if animated { + /* + * NOTE: + * Play an effect that causes media to disappear. + * This animation will not run if there is no deletion. + */ + vanishAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { + if isVisibleMediaDeleted { + visiblePageView.performVanishAnimationBody() + } + self.pageControlBar.performVanishAnimationBody( + for: deletedIdentifiers + ) } - self.pageControlBar.performVanishAnimationBody( - for: deletedIdentifiers - ) + } else { + // Skip vanish animation + vanishAnimator = nil } - vanishAnimator.startAnimation() + vanishAnimator?.startAnimation() // If all media is deleted, close the viewer guard let pagingAfterReloading else { assert(mediaViewerVM.mediaIdentifiers.isEmpty) - navigationController?.popViewController(animated: true) + navigationController?.popViewController(animated: animated) return } - await vanishAnimator.addCompletion() + await vanishAnimator?.addCompletion() - // MARK: Finalize deletion + // MARK: Finalize reloading guard let destination = destinationPageVCAfterReloading else { assertionFailure( @@ -555,23 +565,33 @@ open class MediaViewerViewController: UIPageViewController { return } - let finishAnimator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { - self.pageControlBar.loadItems( - self.mediaViewerVM.mediaIdentifiers, + func finalizeReloading() { + pageControlBar.loadItems( + mediaViewerVM.mediaIdentifiers, expandingItemWith: destination.mediaIdentifier, - animated: true + animated: animated ) if let direction = pagingAfterReloading.direction { - self.move( + move( to: destination, direction: direction, - animated: true + animated: animated ) } } - finishAnimator.startAnimation() - await finishAnimator.addCompletion() + + if animated { + let finishAnimator = UIViewPropertyAnimator( + duration: 0.3, + dampingRatio: 1, + animations: finalizeReloading + ) + finishAnimator.startAnimation() + await finishAnimator.addCompletion() + } else { + finalizeReloading() + } } private func pageDidChange() { From d41058f8c35a533d9acd59b81b73fc9964a2c1cc Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Wed, 29 Nov 2023 23:41:09 +0900 Subject: [PATCH 139/141] fix: animate pop even when animated is false --- Sources/MediaViewer/MediaViewerViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MediaViewer/MediaViewerViewController.swift b/Sources/MediaViewer/MediaViewerViewController.swift index c3bdc9a2..89f362ff 100644 --- a/Sources/MediaViewer/MediaViewerViewController.swift +++ b/Sources/MediaViewer/MediaViewerViewController.swift @@ -542,7 +542,7 @@ open class MediaViewerViewController: UIPageViewController { // If all media is deleted, close the viewer guard let pagingAfterReloading else { assert(mediaViewerVM.mediaIdentifiers.isEmpty) - navigationController?.popViewController(animated: animated) + navigationController?.popViewController(animated: true) return } From 680836c637f01fb7575cb3115601c910c2ed5f2c Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 30 Nov 2023 21:42:47 +0900 Subject: [PATCH 140/141] docs: README --- README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 614f59aa..20c11727 100644 --- a/README.md +++ b/README.md @@ -15,25 +15,32 @@ A comfortable media viewer like the iOS standard. ```swift extension YourViewController: MediaViewerDataSource { - + + // You can specify any type that conforms to `Hashable`. + typealias MediaIdentifier = UIImage + // var images: [UIImage] - func numberOfMedia(in mediaViewer: MediaViewerViewController) -> Int { - images.count + func mediaIdentifiers( + for mediaViewer: MediaViewerViewController + ) -> [MediaIdentifier] { + images } func mediaViewer( _ mediaViewer: MediaViewerViewController, - mediaOnPage page: Int + mediaWith mediaIdentifier: MediaIdentifier // UIImage ) -> Media { - .sync(images[page]) - // Or you can fetch an image asynchronously by using `.async { ... }` + .sync(mediaIdentifier) + // Or you can fetch media asynchronously by `.async { ... }` } - - func transitionSourceView( - forCurrentPageOf mediaViewer: MediaViewerViewController + + func mediaViewer( + _ mediaViewer: MediaViewerViewController, + transitionSourceViewForMediaWith mediaIdentifier: MediaIdentifier ) -> UIView? { - imageViews[mediaViewer.currentPage] + // Return a view that is animated when the viewer opens or closes. + imageView(for: mediaIdentifier) } } ``` @@ -41,8 +48,7 @@ A comfortable media viewer like the iOS standard. 2. Create a `MediaViewerViewController` instance and push it. That's all! :tada: ```swift - let openingPage = 0 - let mediaViewer = MediaViewerViewController(page: openingPage, dataSource: self) + let mediaViewer = MediaViewerViewController(opening: image, dataSource: self) navigationController?.delegate = mediaViewer navigationController?.pushViewController(mediaViewer, animated: true) ``` @@ -54,7 +60,7 @@ See demo for more detailed usage. To use the `MediaViewer` library in a SwiftPM project, add the following line to the dependencies in your `Package.swift` file: ```swift -.package(url: "https://github.com/jrsaruo/MediaViewer", from: "0.0.2"), +.package(url: "https://github.com/jrsaruo/MediaViewer", from: "0.1.0"), ``` and add `MediaViewer` as a dependency for your target: From 1a4a7d3f5e2eaca2d6b9cfca6d1607161d2c704d Mon Sep 17 00:00:00 2001 From: Yusaku Nishi Date: Thu, 30 Nov 2023 22:05:41 +0900 Subject: [PATCH 141/141] docs: add demo gif --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20c11727..6b3da71c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A comfortable media viewer like the iOS standard. -![MediaViewerDemo](https://github.com/jrsaruo/MediaViewer/assets/23174349/6181382d-7b1f-4d79-8752-5ee9727fdef9) +![MediaViewerDemo](https://github.com/jrsaruo/MediaViewer/assets/23174349/6181382d-7b1f-4d79-8752-5ee9727fdef9) ![MediaViewerDemo _camera](https://github.com/jrsaruo/MediaViewer/assets/23174349/efc2b713-ac2f-4c36-8e9f-69b612281e0c) ## Requirements