feat: add media preview for status image

This commit is contained in:
CMK 2021-04-28 15:02:34 +08:00
parent 40b5472d1f
commit 7d8ffd187a
26 changed files with 1928 additions and 13 deletions

View File

@ -254,6 +254,19 @@
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; };
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; };
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; };
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */; };
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */; };
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */; };
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */; };
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */; };
DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */; };
DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EC26391C6C0018D199 /* TransitioningMath.swift */; };
DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */; };
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */; };
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F326391D110018D199 /* MediaPreviewImageView.swift */; };
DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */; };
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */; };
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */; };
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; };
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; };
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; };
@ -678,9 +691,9 @@
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = "<group>"; };
5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
@ -802,6 +815,19 @@
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = "<group>"; };
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = "<group>"; };
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = "<group>"; };
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionController.swift; sourceTree = "<group>"; };
DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionItem.swift; sourceTree = "<group>"; };
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitioningMath.swift; sourceTree = "<group>"; };
DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewController.swift; sourceTree = "<group>"; };
DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewModel.swift; sourceTree = "<group>"; };
DB6180F326391D110018D199 /* MediaPreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageView.swift; sourceTree = "<group>"; };
DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = "<group>"; };
DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = "<group>"; };
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = "<group>"; };
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = "<group>"; };
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = "<group>"; };
@ -1243,6 +1269,7 @@
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
);
path = Vender;
sourceTree = "<group>";
@ -1747,6 +1774,56 @@
path = View;
sourceTree = "<group>";
};
DB6180DE263919350018D199 /* MediaPreview */ = {
isa = PBXGroup;
children = (
DB6180E1263919780018D199 /* Paging */,
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */,
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */,
);
path = MediaPreview;
sourceTree = "<group>";
};
DB6180E1263919780018D199 /* Paging */ = {
isa = PBXGroup;
children = (
DB6180F026391CAB0018D199 /* Image */,
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */,
);
path = Paging;
sourceTree = "<group>";
};
DB6180E426391A500018D199 /* Transition */ = {
isa = PBXGroup;
children = (
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
DB6180E726391B580018D199 /* MediaPreview */,
);
path = Transition;
sourceTree = "<group>";
};
DB6180E726391B580018D199 /* MediaPreview */ = {
isa = PBXGroup;
children = (
DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */,
DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */,
DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */,
DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */,
DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */,
);
path = MediaPreview;
sourceTree = "<group>";
};
DB6180F026391CAB0018D199 /* Image */ = {
isa = PBXGroup;
children = (
DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */,
DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */,
DB6180F326391D110018D199 /* MediaPreviewImageView.swift */,
);
path = Image;
sourceTree = "<group>";
};
DB6804802637CD4C00430867 /* AppShared */ = {
isa = PBXGroup;
children = (
@ -1944,6 +2021,7 @@
children = (
5D03938E2612D200007FE196 /* Webview */,
2D7631A425C1532200929FB9 /* Share */,
DB6180E426391A500018D199 /* Transition */,
DB8AF54E25C13703002E6C99 /* MainTab */,
DB01409B25C40BB600F9F3CF /* Onboarding */,
2D38F1D325CD463600561493 /* HomeTimeline */,
@ -1957,6 +2035,7 @@
DB9D6C0825E4F5A60051B173 /* Profile */,
DB789A1025F9F29B0071ACA0 /* Compose */,
DB938EEB2623F52600E5B6C1 /* Thread */,
DB6180DE263919350018D199 /* MediaPreview */,
);
path = Scene;
sourceTree = "<group>";
@ -2733,6 +2812,7 @@
files = (
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */,
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
@ -2761,8 +2841,10 @@
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */,
5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */,
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */,
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */,
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */,
2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */,
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
@ -2781,6 +2863,7 @@
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
@ -2819,8 +2902,10 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
@ -2828,6 +2913,7 @@
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
@ -2859,6 +2945,7 @@
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
@ -2935,6 +3022,7 @@
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
@ -2950,6 +3038,7 @@
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */,
@ -3013,6 +3102,7 @@
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
@ -3050,10 +3140,12 @@
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,

View File

@ -12,7 +12,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>17</integer>
<integer>14</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -32,7 +32,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>18</integer>
<integer>15</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -71,6 +71,9 @@ extension SceneCoordinator {
// suggestion account
case suggestionAccount(viewModel: SuggestionAccountViewModel)
// media preview
case mediaPreview(viewModel: MediaPreviewViewModel)
// misc
case safari(url: URL)
case alertController(alertController: UIAlertController)
@ -266,6 +269,10 @@ private extension SceneCoordinator {
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mediaPreview(let viewModel):
let _viewController = MediaPreviewViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {

View File

@ -58,9 +58,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
// MARK: - MosciaImageViewContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
@ -76,6 +74,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
extension StatusTableViewCellDelegate where Self: StatusProvider & MediaPreviewableViewController {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index)
}
}
// MARK: - PollTableView
extension StatusTableViewCellDelegate where Self: StatusProvider {

View File

@ -528,6 +528,27 @@ extension StatusProviderFacade {
}
}
extension StatusProviderFacade {
static func coordinateToStatusMediaPreviewScene(provider: StatusProvider & MediaPreviewableViewController, cell: UITableViewCell, mosaicImageView: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
provider.status(for: cell, indexPath: nil)
.sink { [weak provider] status in
guard let provider = provider else { return }
guard let status = status?.reblog ?? status else { return }
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
statusObjectID: status.objectID,
initialIndex: index,
preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image }
)
let mediaPreviewViewModel = MediaPreviewViewModel(context: provider.context, meta: meta)
DispatchQueue.main.async {
provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController))
}
}
.store(in: &provider.disposeBag)
}
}
extension StatusProviderFacade {
enum Target {
case primary // original status

View File

@ -12,7 +12,7 @@ import Combine
import GameplayKit
import CoreData
class HashtagTimelineViewController: UIViewController, NeedsDependency {
class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -20,6 +20,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency {
var viewModel: HashtagTimelineViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color

View File

@ -0,0 +1,353 @@
//
// HomeTimelineViewController+DebugAction.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-2-5.
//
import os.log
import UIKit
import CoreData
import CoreDataStack
#if DEBUG
extension HomeTimelineViewController {
var debugMenu: UIMenu {
let menu = UIMenu(
title: "Debug Tools",
image: nil,
identifier: nil,
options: .displayInline,
children: [
moveMenu,
dropMenu,
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showWelcomeAction(action)
},
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
guard let self = self else { return }
if self.emptyView.superview != nil {
self.emptyView.removeFromSuperview()
} else {
self.showEmptyView()
}
},
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
},
UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showProfileAction(action)
},
UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showThreadAction(action)
},
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showSettings(action)
},
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
}
]
)
return menu
}
var moveMenu: UIMenu {
return UIMenu(
title: "Move to…",
image: UIImage(systemName: "arrow.forward.circle"),
identifier: nil,
options: [],
children: [
UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToTopGapAction(action)
}),
UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstRepliedStatus(action)
}),
UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstReblogStatus(action)
}),
UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstPollStatus(action)
}),
UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstAudioStatus(action)
}),
UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstVideoStatus(action)
}),
UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstGIFStatus(action)
}),
]
)
}
var dropMenu: UIMenu {
return UIMenu(
title: "Drop…",
image: UIImage(systemName: "minus.circle"),
identifier: nil,
options: [],
children: [50, 100, 150, 200, 250, 300].map { count in
UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.dropRecentStatusAction(action, count: count)
})
}
)
}
}
extension HomeTimelineViewController {
@objc private func moveToTopGapAction(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeMiddleLoader: return true
default: return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
}
}
@objc private func moveToFirstReblogStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
return homeTimelineIndex.status.reblog != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found reblog status")
}
}
@objc private func moveToFirstPollStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return post.poll != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found poll status")
}
}
@objc private func moveToFirstRepliedStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
guard homeTimelineIndex.status.inReplyToID != nil else {
return false
}
return true
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found replied status")
}
}
@objc private func moveToFirstAudioStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found audio status")
}
}
@objc private func moveToFirstVideoStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found video status")
}
}
@objc private func moveToFirstGIFStatus(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found GIF status")
}
}
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in
switch item {
case .homeTimelineIndex(let objectID, _): return objectID
default: return nil
}
}
var droppingStatusObjectIDs: [NSManagedObjectID] = []
context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingObjectIDs {
guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue }
droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID)
self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex)
}
}
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingStatusObjectIDs {
guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue }
self.context.apiService.backgroundManagedObjectContext.delete(post)
}
}
.sink { _ in
// do nothing
}
.store(in: &self.disposeBag)
case .failure(let error):
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
@objc private func showWelcomeAction(_ sender: UIAction) {
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}
@objc private func showProfileAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
@objc private func showThreadAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "")
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
@objc private func showSettings(_ sender: UIAction) {
<<<<<<< HEAD
guard let currentSetting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
=======
let viewModel = SettingsViewModel(context: context)
coordinator.present(
scene: .settings(viewModel: viewModel),
from: self,
transition: .modal(animated: true, completion: nil)
)
>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d
}
}
#endif

View File

@ -15,7 +15,7 @@ import GameplayKit
import MastodonSDK
import AlamofireImage
final class HomeTimelineViewController: UIViewController, NeedsDependency {
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var emptyView: UIStackView = {
let emptyView = UIStackView()
emptyView.axis = .vertical

View File

@ -0,0 +1,134 @@
//
// MediaPreviewViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
import Combine
import Pageboy
final class MediaPreviewViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: MediaPreviewViewModel!
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let pagingViewConttroller = MediaPreviewPagingViewController()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension MediaPreviewViewController {
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .dark
visualEffectView.frame = view.bounds
view.addSubview(visualEffectView)
pagingViewConttroller.view.translatesAutoresizingMaskIntoConstraints = false
addChild(pagingViewConttroller)
visualEffectView.contentView.addSubview(pagingViewConttroller.view)
NSLayoutConstraint.activate([
visualEffectView.topAnchor.constraint(equalTo: pagingViewConttroller.view.topAnchor),
visualEffectView.bottomAnchor.constraint(equalTo: pagingViewConttroller.view.bottomAnchor),
visualEffectView.leadingAnchor.constraint(equalTo: pagingViewConttroller.view.leadingAnchor),
visualEffectView.trailingAnchor.constraint(equalTo: pagingViewConttroller.view.trailingAnchor),
])
pagingViewConttroller.didMove(toParent: self)
viewModel.mediaPreviewImageViewControllerDelegate = self
pagingViewConttroller.interPageSpacing = 10
pagingViewConttroller.delegate = self
pagingViewConttroller.dataSource = viewModel
}
}
// MARK: - MediaPreviewingViewController
extension MediaPreviewViewController: MediaPreviewingViewController {
func isInteractiveDismissable() -> Bool {
return true
// if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController {
// let previewImageView = mediaPreviewImageViewController.previewImageView
// // TODO: allow zooming pan dismiss
// guard previewImageView.zoomScale == previewImageView.minimumZoomScale else {
// return false
// }
//
// let safeAreaInsets = previewImageView.safeAreaInsets
// let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
// return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight)
// }
//
// return false
}
}
// MARK: - PageboyViewControllerDelegate
extension MediaPreviewViewController: PageboyViewControllerDelegate {
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
willScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollTo position: CGPoint,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// update page control
// pageControl.currentPage = index
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didReloadWith currentViewController: UIViewController,
currentPageIndex: PageboyViewController.PageIndex
) {
// do nothing
}
}
// MARK: - MediaPreviewImageViewControllerDelegate
extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
}
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
// delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer)
}
}

View File

@ -0,0 +1,92 @@
//
// MediaPreviewViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import Pageboy
final class MediaPreviewViewModel: NSObject {
// input
let context: AppContext
let initialItem: PreviewItem
weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate?
// output
let viewControllers: [UIViewController]
init(context: AppContext, meta: StatusImagePreviewMeta) {
self.context = context
self.initialItem = .status(meta)
var viewControllers: [UIViewController] = []
let managedObjectContext = self.context.managedObjectContext
managedObjectContext.performAndWait {
let status = managedObjectContext.object(with: meta.statusObjectID) as! Status
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return }
for (entity, image) in zip(media, meta.preloadThumbnailImages) {
let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil }
switch entity.type {
case .image:
guard let url = URL(string: entity.url) else { continue }
let meta = MediaPreviewImageViewModel.StatusImagePreviewMeta(url: url, thumbnail: thumbnail)
let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
let mediaPreviewImageViewController = MediaPreviewImageViewController()
mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
viewControllers.append(mediaPreviewImageViewController)
default:
continue
}
}
}
self.viewControllers = viewControllers
super.init()
}
}
extension MediaPreviewViewModel {
enum PreviewItem {
case status(StatusImagePreviewMeta)
case local(LocalImagePreviewMeta)
}
struct StatusImagePreviewMeta {
let statusObjectID: NSManagedObjectID
let initialIndex: Int
let preloadThumbnailImages: [UIImage?]
}
struct LocalImagePreviewMeta {
let image: UIImage
}
}
// MARK: - PageboyViewControllerDataSource
extension MediaPreviewViewModel: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
let viewController = viewControllers[index]
if let mediaPreviewImageViewController = viewController as? MediaPreviewImageViewController {
mediaPreviewImageViewController.delegate = mediaPreviewImageViewControllerDelegate
}
return viewController
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
guard case let .status(meta) = initialItem else { return nil }
return .at(index: meta.initialIndex)
}
}

View File

@ -0,0 +1,214 @@
//
// MediaPreviewImageView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import func AVFoundation.AVMakeRect
import UIKit
final class MediaPreviewImageView: UIScrollView {
let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.isUserInteractionEnabled = true
return imageView
}()
let doubleTapGestureRecognizer: UITapGestureRecognizer = {
let tapGestureRecognizer = UITapGestureRecognizer()
tapGestureRecognizer.numberOfTapsRequired = 2
return tapGestureRecognizer
}()
private var containerFrame: CGRect?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension MediaPreviewImageView {
private func _init() {
isUserInteractionEnabled = true
showsVerticalScrollIndicator = false
showsHorizontalScrollIndicator = false
bouncesZoom = true
minimumZoomScale = 1.0
maximumZoomScale = 4.0
addSubview(imageView)
doubleTapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageView.doubleTapGestureRecognizerHandler(_:)))
imageView.addGestureRecognizer(doubleTapGestureRecognizer)
delegate = self
}
override func layoutSubviews() {
super.layoutSubviews()
guard let image = imageView.image else { return }
setup(image: image, container: self)
}
}
extension MediaPreviewImageView {
@objc private func doubleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let middleZoomScale = 0.5 * maximumZoomScale
if zoomScale >= middleZoomScale {
setZoomScale(minimumZoomScale, animated: true)
} else {
let center = sender.location(in: imageView)
let zoomRect: CGRect = {
let width = bounds.width / middleZoomScale
let height = bounds.height / middleZoomScale
return CGRect(
x: center.x - 0.5 * width,
y: center.y - 0.5 * height,
width: width,
height: height
)
}()
zoom(to: zoomRect, animated: true)
}
}
}
extension MediaPreviewImageView {
func setup(image: UIImage, container: UIView, forceUpdate: Bool = false) {
guard image.size.width > 0, image.size.height > 0 else { return }
guard container.bounds.width > 0, container.bounds.height > 0 else { return }
// do not setup when frame not change except force update
if containerFrame == container.frame && !forceUpdate {
return
}
containerFrame = container.frame
// reset to normal
zoomScale = minimumZoomScale
let imageViewSize = AVMakeRect(aspectRatio: image.size, insideRect: container.bounds).size
let imageContentInset: UIEdgeInsets = {
if imageViewSize.width == container.bounds.width {
return UIEdgeInsets(top: 0.5 * (container.bounds.height - imageViewSize.height), left: 0, bottom: 0, right: 0)
} else {
return UIEdgeInsets(top: 0, left: 0.5 * (container.bounds.width - imageViewSize.width), bottom: 0, right: 0)
}
}()
imageView.frame = CGRect(origin: .zero, size: imageViewSize)
imageView.image = image
contentSize = imageViewSize
contentInset = imageContentInset
centerScrollViewContents()
contentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup image for container %s", ((#file as NSString).lastPathComponent), #line, #function, container.frame.debugDescription)
}
}
// MARK: - UIScrollViewDelegate
extension MediaPreviewImageView: UIScrollViewDelegate {
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
return false
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
centerScrollViewContents()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
// Ref: https://stackoverflow.com/questions/14069571/keep-zoomable-image-in-center-of-uiscrollview
extension MediaPreviewImageView {
private var scrollViewVisibleSize: CGSize {
let contentInset = self.contentInset
let scrollViewSize = bounds.standardized.size
let width = scrollViewSize.width - contentInset.left - contentInset.right
let height = scrollViewSize.height - contentInset.top - contentInset.bottom
return CGSize(width: width, height: height)
}
private var scrollViewCenter: CGPoint {
let scrollViewSize = self.scrollViewVisibleSize
return CGPoint(x: scrollViewSize.width / 2.0,
y: scrollViewSize.height / 2.0)
}
private func centerScrollViewContents() {
guard let image = imageView.image else { return }
let imageViewSize = imageView.frame.size
let imageSize = image.size
var realImageSize: CGSize
if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height {
realImageSize = CGSize(width: imageViewSize.width,
height: imageViewSize.width / imageSize.width * imageSize.height)
} else {
realImageSize = CGSize(width: imageViewSize.height / imageSize.height * imageSize.width,
height: imageViewSize.height)
}
var frame = CGRect.zero
frame.size = realImageSize
imageView.frame = frame
let screenSize = self.frame.size
let offsetX = screenSize.width > realImageSize.width ? (screenSize.width - realImageSize.width) / 2 : 0
let offsetY = screenSize.height > realImageSize.height ? (screenSize.height - realImageSize.height) / 2 : 0
contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: offsetY, right: offsetX)
// The scroll view has zoomed, so you need to re-center the contents
let scrollViewSize = scrollViewVisibleSize
// First assume that image center coincides with the contents box center.
// This is correct when the image is bigger than scrollView due to zoom
var imageCenter = CGPoint(x: contentSize.width / 2.0,
y: contentSize.height / 2.0)
let center = scrollViewCenter
//if image is smaller than the scrollView visible size - fix the image center accordingly
if contentSize.width < scrollViewSize.width {
imageCenter.x = center.x
}
if contentSize.height < scrollViewSize.height {
imageCenter.y = center.y
}
imageView.center = imageCenter
}
}

View File

@ -0,0 +1,115 @@
//
// MediaPreviewImageViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
import Combine
protocol MediaPreviewImageViewControllerDelegate: class {
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer)
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer)
}
final class MediaPreviewImageViewController: UIViewController {
var disposeBag = Set<AnyCancellable>()
var viewModel: MediaPreviewImageViewModel!
weak var delegate: MediaPreviewImageViewControllerDelegate?
// let progressBarView = ProgressBarView()
let previewImageView = MediaPreviewImageView()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
let longPressGestureRecognizer = UILongPressGestureRecognizer()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
previewImageView.imageView.af.cancelImageRequest()
}
}
extension MediaPreviewImageViewController {
override func viewDidLoad() {
super.viewDidLoad()
// progressBarView.tintColor = .white
// progressBarView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(progressBarView)
// NSLayoutConstraint.activate([
// progressBarView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
// progressBarView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
// progressBarView.widthAnchor.constraint(equalToConstant: 120),
// progressBarView.heightAnchor.constraint(equalToConstant: 44),
// ])
previewImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(previewImageView)
NSLayoutConstraint.activate([
previewImageView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
previewImageView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:)))
longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:)))
tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer)
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
previewImageView.addGestureRecognizer(tapGestureRecognizer)
previewImageView.addGestureRecognizer(longPressGestureRecognizer)
switch viewModel.item {
case .status(let meta):
// progressBarView.isHidden = meta.thumbnail != nil
previewImageView.imageView.af.setImage(
withURL: meta.url,
placeholderImage: meta.thumbnail,
filter: nil,
progress: { [weak self] progress in
guard let self = self else { return }
// self.progressBarView.progress.value = CGFloat(progress.fractionCompleted)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %s progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription, progress.fractionCompleted)
},
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: { [weak self] response in
guard let self = self else { return }
switch response.result {
case .success(let image):
//self.progressBarView.isHidden = true
self.previewImageView.imageView.image = image
self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true)
case .failure(let error):
// TODO:
break
}
}
)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setImage url: %s", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription)
case .local(let meta):
// progressBarView.isHidden = true
previewImageView.imageView.image = meta.image
self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true)
}
}
}
extension MediaPreviewImageViewController {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.mediaPreviewImageViewController(self, tapGestureRecognizerDidTrigger: sender)
}
@objc private func longPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.mediaPreviewImageViewController(self, longPressGestureRecognizerDidTrigger: sender)
}
}

View File

@ -0,0 +1,41 @@
//
// MediaPreviewImageViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import UIKit
import Combine
class MediaPreviewImageViewModel {
// input
let item: ImagePreviewItem
init(meta: StatusImagePreviewMeta) {
self.item = .status(meta)
}
init(meta: LocalImagePreviewMeta) {
self.item = .local(meta)
}
}
extension MediaPreviewImageViewModel {
enum ImagePreviewItem {
case status(StatusImagePreviewMeta)
case local(LocalImagePreviewMeta)
}
struct StatusImagePreviewMeta {
let url: URL
let thumbnail: UIImage?
}
struct LocalImagePreviewMeta {
let image: UIImage
}
}

View File

@ -0,0 +1,11 @@
//
// MediaPreviewPagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import UIKit
import Pageboy
final class MediaPreviewPagingViewController: PageboyViewController { }

View File

@ -14,7 +14,7 @@ import AVKit
import Combine
import GameplayKit
final class FavoriteViewController: UIViewController, NeedsDependency {
final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -22,6 +22,8 @@ final class FavoriteViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: FavoriteViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let titleView = DoubleTitleLabelNavigationBarTitleView()
lazy var tableView: UITableView = {

View File

@ -13,7 +13,7 @@ import CoreDataStack
import GameplayKit
// TODO: adopt MediaPreviewableViewController
final class UserTimelineViewController: UIViewController, NeedsDependency {
final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -21,7 +21,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: UserTimelineViewModel!
// let mediaPreviewTransitionController = MediaPreviewTransitionController()
let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()

View File

@ -13,13 +13,15 @@ import GameplayKit
import os.log
import UIKit
final class PublicTimelineViewController: UIViewController, NeedsDependency {
final class PublicTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: PublicTimelineViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let refreshControl = UIRefreshControl()
lazy var tableView: UITableView = {

View File

@ -0,0 +1,215 @@
//
// SettingsViewModel.swift
// Mastodon
//
// Created by ihugo on 2021/4/7.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import os.log
<<<<<<< HEAD
class SettingsViewModel {
=======
class SettingsViewModel: NSObject {
// confirm set only once
weak var context: AppContext! { willSet { precondition(context == nil) } }
>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d
var disposeBag = Set<AnyCancellable>()
let context: AppContext
// input
let setting: CurrentValueSubject<Setting, Never>
var updateDisposeBag = Set<AnyCancellable>()
var createDisposeBag = Set<AnyCancellable>()
let viewDidLoad = PassthroughSubject<Void, Never>()
// output
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
/// create a subscription when:
/// - does not has one
/// - does not find subscription for selected trigger when change trigger
let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
/// update a subscription when:
/// - change switch for specified alerts
let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
lazy var privacyURL: URL? = {
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
return nil
}
return Mastodon.API.privacyURL(domain: box.domain)
}()
<<<<<<< HEAD
init(context: AppContext, setting: Setting) {
self.context = context
self.setting = CurrentValueSubject(setting)
=======
/// to store who trigger the notification.
var triggerBy: String?
struct Input {
}
struct Output {
}
init(context: AppContext) {
self.context = context
>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d
self.setting
.sink(receiveValue: { [weak self] setting in
guard let self = self else { return }
self.processDataSource(setting)
})
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension SettingsViewModel {
// MARK: - Private methods
private func processDataSource(_ setting: Setting) {
guard let dataSource = self.dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
// appearance
let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)]
snapshot.appendSections([.apperance])
snapshot.appendItems(appearanceItems, toSection: .apperance)
let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in
SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode)
}
snapshot.appendSections([.notifications])
snapshot.appendItems(notificationItems, toSection: .notifications)
// boring zone
let boringZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.termsOfService,
.privacyPolicy
]
let items = links.map { SettingsItem.boringZone(item: $0) }
return items
}()
snapshot.appendSections([.boringZone])
snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone)
let spicyZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.clearMediaCache,
.signOut
]
let items = links.map { SettingsItem.spicyZone(item: $0) }
return items
}()
snapshot.appendSections([.spicyZone])
snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension SettingsViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate,
settingsToggleCellDelegate: SettingsToggleCellDelegate
) {
dataSource = UITableViewDiffableDataSource(tableView: tableView) { [
weak self,
weak settingsAppearanceTableViewCellDelegate,
weak settingsToggleCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
guard let self = self else { return nil }
switch item {
case .apperance(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
cell.update(with: setting.appearance)
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
cell.update(with: setting.appearance)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsAppearanceTableViewCellDelegate
return cell
case .notification(let objectID, let switchMode):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
if let subscription = setting.activeSubscription {
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
}
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
guard let subscription = setting.activeSubscription else { return }
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsToggleCellDelegate
return cell
case .boringZone(let item), .spicyZone(let item):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell
cell.update(with: item)
return cell
}
}
processDataSource(self.setting.value)
}
}
extension SettingsViewModel {
static func configureSettingToggle(
cell: SettingsToggleTableViewCell,
switchMode: SettingsItem.NotificationSwitchMode,
subscription: NotificationSubscription
) {
cell.textLabel?.text = switchMode.title
let enabled: Bool?
switch switchMode {
case .favorite: enabled = subscription.alert.favourite
case .follow: enabled = subscription.alert.follow
case .reblog: enabled = subscription.alert.reblog
case .mention: enabled = subscription.alert.mention
}
cell.update(enabled: enabled)
}
}

View File

@ -11,7 +11,7 @@ import Combine
import CoreData
import AVKit
final class ThreadViewController: UIViewController, NeedsDependency {
final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -19,6 +19,8 @@ final class ThreadViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: ThreadViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let titleView = DoubleTitleLabelNavigationBarTitleView()
let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem(

View File

@ -0,0 +1,299 @@
//
// MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning {
let transitionItem: MediaPreviewTransitionItem
let panGestureRecognizer: UIPanGestureRecognizer
private var isTransitionContextFinish = false
private var popInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero)
private var itemInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero)
init(operation: UINavigationController.Operation, transitionItem: MediaPreviewTransitionItem, panGestureRecognizer: UIPanGestureRecognizer) {
self.transitionItem = transitionItem
self.panGestureRecognizer = panGestureRecognizer
super.init(operation: operation)
}
class func animator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator {
let timingParameters = UISpringTimingParameters(mass: 4.0, stiffness: 1300, damping: 180, initialVelocity: initialVelocity)
return UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
}
}
// MARK: - UIViewControllerAnimatedTransitioning
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
super.animateTransition(using: transitionContext)
switch operation {
case .push: pushTransition(using: transitionContext).startAnimation()
case .pop: popTransition(using: transitionContext).startAnimation()
default: return
}
}
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
guard let toVC = transitionContext.viewController(forKey: .to) as? MediaPreviewViewController,
let toView = transitionContext.view(forKey: .to) else {
fatalError()
}
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
toView.frame = toViewEndFrame
toView.alpha = 0
transitionContext.containerView.addSubview(toView)
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
animator.addAnimations {
toView.alpha = 1
}
animator.addCompletion { position in
transitionContext.completeTransition(position == .end)
}
return animator
}
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
let fromView = transitionContext.view(forKey: .from) else {
fatalError()
}
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
animator.addAnimations {
fromView.alpha = 0
}
animator.addCompletion { position in
transitionContext.completeTransition(position == .end)
}
return animator
}
}
// MARK: - UIViewControllerInteractiveTransitioning
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
super.startInteractiveTransition(transitionContext)
switch operation {
case .pop:
guard let mediaPreviewViewController = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
let mediaPreviewImageViewController = mediaPreviewViewController.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController else {
transitionContext.completeTransition(false)
return
}
let imageView = mediaPreviewImageViewController.previewImageView.imageView
let _snapshot: UIView? = {
// if imageView.image == nil {
// transitionItem.snapshotRaw = mediaPreviewImageViewController.progressBarView
// return mediaPreviewImageViewController.progressBarView.snapshotView(afterScreenUpdates: false)
// } else {
transitionItem.snapshotRaw = imageView
return imageView.snapshotView(afterScreenUpdates: false)
// }
}()
guard let snapshot = _snapshot else {
transitionContext.completeTransition(false)
return
}
mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView)
snapshot.center = transitionContext.containerView.center
transitionItem.imageView = imageView
transitionItem.snapshotTransitioning = snapshot
transitionItem.initialFrame = snapshot.frame
transitionItem.targetFrame = snapshot.frame
panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:)))
popInteractiveTransition(using: transitionContext)
default:
assertionFailure()
return
}
}
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
let fromView = transitionContext.view(forKey: .from) else {
fatalError()
}
let animator = popInteractiveTransitionAnimator
let blurEffect = fromVC.visualEffectView.effect
self.transitionItem.imageView?.isHidden = true
self.transitionItem.snapshotRaw?.alpha = 0.0
animator.addAnimations {
self.transitionItem.snapshotTransitioning?.alpha = 0.4
// fromVC.mediaInfoDescriptionView.alpha = 0
// fromVC.closeButtonBackground.alpha = 0
// fromVC.pageControl.alpha = 0
fromVC.visualEffectView.effect = nil
}
animator.addCompletion { position in
self.transitionItem.imageView?.isHidden = position == .end
self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0
self.transitionItem.snapshotTransitioning?.removeFromSuperview()
fromVC.visualEffectView.effect = position == .end ? nil : blurEffect
transitionContext.completeTransition(position == .end)
}
}
}
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
@objc func updatePanGestureInteractive(_ sender: UIPanGestureRecognizer) {
guard !isTransitionContextFinish else { return } // do not accept transition abort
switch sender.state {
case .began, .changed:
let translation = sender.translation(in: transitionContext.containerView)
let percent = popInteractiveTransitionAnimator.fractionComplete + progressStep(for: translation)
popInteractiveTransitionAnimator.fractionComplete = percent
transitionContext.updateInteractiveTransition(percent)
updateTransitionItemPosition(of: translation)
// Reset translation to zero
sender.setTranslation(CGPoint.zero, in: transitionContext.containerView)
case .ended, .cancelled:
let targetPosition = completionPosition()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: target position: %s", ((#file as NSString).lastPathComponent), #line, #function, targetPosition == .end ? "end" : "start")
targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition()
isTransitionContextFinish = true
animate(targetPosition)
default:
return
}
}
private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector {
guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else {
return CGVector.zero
}
let dx = abs(targetFrame.midX - currentFrame.midX)
let dy = abs(targetFrame.midY - currentFrame.midY)
guard dx > 0.0 && dy > 0.0 else {
return CGVector.zero
}
let range = CGFloat(35.0)
let clippedVx = clip(-range, range, velocity.x / dx)
let clippedVy = clip(-range, range, velocity.y / dy)
return CGVector(dx: clippedVx, dy: clippedVy)
}
private func completionPosition() -> UIViewAnimatingPosition {
let completionThreshold: CGFloat = 0.33
let flickMagnitude: CGFloat = 1200 // pts/sec
let velocity = panGestureRecognizer.velocity(in: transitionContext.containerView).vector
let isFlick = (velocity.magnitude > flickMagnitude)
let isFlickDown = isFlick && (velocity.dy > 0.0)
let isFlickUp = isFlick && (velocity.dy < 0.0)
if (operation == .push && isFlickUp) || (operation == .pop && isFlickDown) {
return .end
} else if (operation == .push && isFlickDown) || (operation == .pop && isFlickUp) {
return .start
} else if popInteractiveTransitionAnimator.fractionComplete > completionThreshold {
return .end
} else {
return .start
}
}
// Create item animator and start it
func animate(_ toPosition: UIViewAnimatingPosition) {
// Create a property animator to animate each image's frame change
let gestureVelocity = panGestureRecognizer.velocity(in: transitionContext.containerView)
let velocity = convert(gestureVelocity, for: transitionItem)
let itemAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: velocity)
itemAnimator.addAnimations {
if toPosition == .end {
self.transitionItem.snapshotTransitioning?.alpha = 0
} else {
self.transitionItem.snapshotTransitioning?.alpha = 1
self.transitionItem.snapshotTransitioning?.frame = self.transitionItem.initialFrame!
}
}
// Start the property animator and keep track of it
self.itemInteractiveTransitionAnimator = itemAnimator
itemAnimator.startAnimation()
// Reverse the transition animator if we are returning to the start position
popInteractiveTransitionAnimator.isReversed = (toPosition == .start)
if popInteractiveTransitionAnimator.state == .inactive {
popInteractiveTransitionAnimator.startAnimation()
} else {
let durationFactor = CGFloat(itemAnimator.duration / popInteractiveTransitionAnimator.duration)
popInteractiveTransitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor)
}
}
private func progressStep(for translation: CGPoint) -> CGFloat {
return (operation == .push ? -1.0 : 1.0) * translation.y / transitionContext.containerView.bounds.midY
}
private func updateTransitionItemPosition(of translation: CGPoint) {
let progress = progressStep(for: translation)
let initialSize = transitionItem.initialFrame!.size
assert(initialSize != .zero)
guard let snapshot = transitionItem.snapshotTransitioning,
let finalSize = transitionItem.targetFrame?.size else {
return
}
if snapshot.frame.size == .zero {
snapshot.frame.size = initialSize
}
let currentSize = snapshot.frame.size
let itemPercentComplete = clip(-0.05, 1.05, (currentSize.width - initialSize.width) / (finalSize.width - initialSize.width) + progress)
let itemWidth = lerp(initialSize.width, finalSize.width, itemPercentComplete)
let itemHeight = lerp(initialSize.height, finalSize.height, itemPercentComplete)
assert(currentSize.width != 0.0)
assert(currentSize.height != 0.0)
let scaleTransform = CGAffineTransform(scaleX: (itemWidth / currentSize.width), y: (itemHeight / currentSize.height))
let scaledOffset = transitionItem.touchOffset.apply(transform: scaleTransform)
snapshot.center = (snapshot.center + (translation + (transitionItem.touchOffset - scaledOffset))).point
snapshot.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: itemWidth, height: itemHeight))
transitionItem.touchOffset = scaledOffset
}
}

View File

@ -0,0 +1,126 @@
//
// MediaPreviewTransitionController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
final class MediaPreviewTransitionController: NSObject {
weak var mediaPreviewViewController: MediaPreviewViewController?
var wantsInteractiveStart = false
private var panGestureRecognizer: UIPanGestureRecognizer = {
let gestureRecognizer = UIPanGestureRecognizer()
gestureRecognizer.maximumNumberOfTouches = 1
return gestureRecognizer
}()
private var dismissInteractiveTransitioning: MediaHostToMediaPreviewViewControllerAnimatedTransitioning?
override init() {
super.init()
panGestureRecognizer.delegate = self
panGestureRecognizer.addTarget(self, action: #selector(MediaPreviewTransitionController.panGestureRecognizerHandler(_:)))
}
}
extension MediaPreviewTransitionController {
@objc private func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) {
guard dismissInteractiveTransitioning == nil else { return }
guard let mediaPreviewViewController = self.mediaPreviewViewController else { return }
wantsInteractiveStart = true
mediaPreviewViewController.dismiss(animated: true, completion: nil)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: start interactive dismiss", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - UIGestureRecognizerDelegate
extension MediaPreviewTransitionController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
// FIXME: should enable zoom up pan dismiss
return false
}
return true
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
guard let mediaPreviewViewController = self.mediaPreviewViewController else { return false }
return mediaPreviewViewController.isInteractiveDismissable()
}
return false
}
}
// MARK: - UIViewControllerTransitioningDelegate
extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let mediaPreviewViewController = presented as? MediaPreviewViewController else {
assertionFailure()
return nil
}
self.mediaPreviewViewController = mediaPreviewViewController
self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer)
let transitionItem = MediaPreviewTransitionItem(id: UUID())
return MediaHostToMediaPreviewViewControllerAnimatedTransitioning(
operation: .push,
transitionItem: transitionItem,
panGestureRecognizer: panGestureRecognizer
)
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
// not support interactive present
return nil
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let mediaPreviewViewController = dismissed as? MediaPreviewViewController else {
assertionFailure()
return nil
}
let transitionItem = MediaPreviewTransitionItem(id: UUID())
return MediaHostToMediaPreviewViewControllerAnimatedTransitioning(
operation: .pop,
transitionItem: transitionItem,
panGestureRecognizer: panGestureRecognizer
)
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let transitioning = animator as? MediaHostToMediaPreviewViewControllerAnimatedTransitioning,
transitioning.operation == .pop, wantsInteractiveStart else {
return nil
}
dismissInteractiveTransitioning = transitioning
transitioning.delegate = self
return transitioning
}
}
// MARK: - ViewControllerAnimatedTransitioningDelegate
extension MediaPreviewTransitionController: ViewControllerAnimatedTransitioningDelegate {
func animationEnded(_ transitionCompleted: Bool) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: completed: %s", ((#file as NSString).lastPathComponent), #line, #function, transitionCompleted.description)
dismissInteractiveTransitioning = nil
wantsInteractiveStart = false
}
}

View File

@ -0,0 +1,26 @@
//
// MediaPreviewTransitionItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import UIKit
class MediaPreviewTransitionItem: Identifiable {
let id: UUID
// TODO:
var imageView: UIImageView?
var snapshotRaw: UIView?
var snapshotTransitioning: UIView?
var initialFrame: CGRect? = nil
var targetFrame: CGRect? = nil
var touchOffset: CGVector = CGVector.zero
init(id: UUID) {
self.id = id
}
}

View File

@ -0,0 +1,12 @@
//
// MediaPreviewableViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import Foundation
protocol MediaPreviewableViewController: class {
var mediaPreviewTransitionController: MediaPreviewTransitionController { get }
}

View File

@ -0,0 +1,12 @@
//
// MediaPreviewingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import Foundation
protocol MediaPreviewingViewController: class {
func isInteractiveDismissable() -> Bool
}

View File

@ -0,0 +1,65 @@
//
// ViewControllerAnimatedTransitioning.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
protocol ViewControllerAnimatedTransitioningDelegate: AnyObject {
var wantsInteractiveStart: Bool { get }
func animationEnded(_ transitionCompleted: Bool)
}
class ViewControllerAnimatedTransitioning: NSObject {
let operation: UINavigationController.Operation
var transitionContext: UIViewControllerContextTransitioning!
var isInteractive: Bool { return transitionContext.isInteractive }
weak var delegate: ViewControllerAnimatedTransitioningDelegate?
init(operation: UINavigationController.Operation) {
assert(operation != .none)
self.operation = operation
super.init()
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - UIViewControllerAnimatedTransitioning
extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
}
func animationEnded(_ transitionCompleted: Bool) {
delegate?.animationEnded(transitionCompleted)
}
}
// MARK: - UIViewControllerInteractiveTransitioning
extension ViewControllerAnimatedTransitioning: UIViewControllerInteractiveTransitioning {
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
}
var wantsInteractiveStart: Bool {
return delegate?.wantsInteractiveStart ?? false
}
}

View File

@ -0,0 +1,66 @@
/*
Copyright (C) 2016 Apple Inc. All Rights Reserved.
See LICENSE.txt for this samples licensing information
Abstract:
Convenience math operators
*/
import QuartzCore
func clip<T : Comparable>(_ x0: T, _ x1: T, _ v: T) -> T {
return max(x0, min(x1, v))
}
func lerp<T : FloatingPoint>(_ v0: T, _ v1: T, _ t: T) -> T {
return v0 + (v1 - v0) * t
}
func -(lhs: CGPoint, rhs: CGPoint) -> CGVector {
return CGVector(dx: lhs.x - rhs.x, dy: lhs.y - rhs.y)
}
func -(lhs: CGPoint, rhs: CGVector) -> CGPoint {
return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy)
}
func -(lhs: CGVector, rhs: CGVector) -> CGVector {
return CGVector(dx: lhs.dx - rhs.dx, dy: lhs.dy - rhs.dy)
}
func +(lhs: CGPoint, rhs: CGPoint) -> CGVector {
return CGVector(dx: lhs.x + rhs.x, dy: lhs.y + rhs.y)
}
func +(lhs: CGPoint, rhs: CGVector) -> CGPoint {
return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy)
}
func +(lhs: CGVector, rhs: CGVector) -> CGVector {
return CGVector(dx: lhs.dx + rhs.dx, dy: lhs.dy + rhs.dy)
}
func *(left: CGVector, right:CGFloat) -> CGVector {
return CGVector(dx: left.dx * right, dy: left.dy * right)
}
extension CGPoint {
var vector: CGVector {
return CGVector(dx: x, dy: y)
}
}
extension CGVector {
var magnitude: CGFloat {
return sqrt(dx*dx + dy*dy)
}
var point: CGPoint {
return CGPoint(x: dx, y: dy)
}
func apply(transform t: CGAffineTransform) -> CGVector {
return point.applying(t).vector
}
}