From 7d8ffd187a79e33c29b4806a7bcdad56d215598e Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 15:02:34 +0800 Subject: [PATCH 01/13] feat: add media preview for status image --- Mastodon.xcodeproj/project.pbxproj | 94 ++++- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Coordinator/SceneCoordinator.swift | 7 + ...Provider+StatusTableViewCellDelegate.swift | 10 +- .../StatusProvider/StatusProviderFacade.swift | 21 ++ .../HashtagTimelineViewController.swift | 4 +- ...elineViewController+DebugAction.swift.orig | 353 ++++++++++++++++++ .../HomeTimelineViewController.swift | 4 +- .../MediaPreviewViewController.swift | 134 +++++++ .../MediaPreview/MediaPreviewViewModel.swift | 92 +++++ .../Paging/Image/MediaPreviewImageView.swift | 214 +++++++++++ .../MediaPreviewImageViewController.swift | 115 ++++++ .../Image/MediaPreviewImageViewModel.swift | 41 ++ .../MediaPreviewPagingViewController.swift | 11 + .../Favorite/FavoriteViewController.swift | 4 +- .../Timeline/UserTimelineViewController.swift | 4 +- .../PublicTimelineViewController.swift | 4 +- .../Settings/SettingsViewModel.swift.orig | 215 +++++++++++ .../Scene/Thread/ThreadViewController.swift | 4 +- ...wViewControllerAnimatedTransitioning.swift | 299 +++++++++++++++ .../MediaPreviewTransitionController.swift | 126 +++++++ .../MediaPreviewTransitionItem.swift | 26 ++ .../MediaPreviewableViewController.swift | 12 + .../MediaPreviewingViewController.swift | 12 + .../ViewControllerAnimatedTransitioning.swift | 65 ++++ Mastodon/Vender/TransitioningMath.swift | 66 ++++ 26 files changed, 1928 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig create mode 100644 Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift create mode 100644 Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift create mode 100644 Mastodon/Scene/Settings/SettingsViewModel.swift.orig create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift create mode 100644 Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift create mode 100644 Mastodon/Vender/TransitioningMath.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index adbfa70a..80ce2d0e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; - 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 = ""; }; 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -802,6 +815,19 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; + DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = ""; }; + DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = ""; }; + DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionController.swift; sourceTree = ""; }; + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionItem.swift; sourceTree = ""; }; + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitioningMath.swift; sourceTree = ""; }; + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewController.swift; sourceTree = ""; }; + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewModel.swift; sourceTree = ""; }; + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageView.swift; sourceTree = ""; }; + DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = ""; }; + DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = ""; }; + DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; @@ -1243,6 +1269,7 @@ DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, ); path = Vender; sourceTree = ""; @@ -1747,6 +1774,56 @@ path = View; sourceTree = ""; }; + DB6180DE263919350018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E1263919780018D199 /* Paging */, + DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */, + DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180E1263919780018D199 /* Paging */ = { + isa = PBXGroup; + children = ( + DB6180F026391CAB0018D199 /* Image */, + DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DB6180E426391A500018D199 /* Transition */ = { + isa = PBXGroup; + children = ( + DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */, + DB6180E726391B580018D199 /* MediaPreview */, + ); + path = Transition; + sourceTree = ""; + }; + DB6180E726391B580018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */, + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */, + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */, + DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */, + DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180F026391CAB0018D199 /* Image */ = { + isa = PBXGroup; + children = ( + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */, + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */, + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */, + ); + path = Image; + sourceTree = ""; + }; 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 = ""; @@ -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 */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 083bcfbb..32685726 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 17 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 18 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index a71947e2..3768f7d3 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -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 { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 198f0a4a..bc0a8b2d 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -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 { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 03f84216..8178ae95 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -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 diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ea1a03aa..7a340473 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -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 diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig new file mode 100644 index 00000000..a47aded6 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig @@ -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 diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 3329ce8d..8932346e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -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() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + let mediaPreviewTransitionController = MediaPreviewTransitionController() + lazy var emptyView: UIStackView = { let emptyView = UIStackView() emptyView.axis = .vertical diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift new file mode 100644 index 00000000..8845fff8 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -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() + 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) + } + +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift new file mode 100644 index 00000000..744eab44 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -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) + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift new file mode 100644 index 00000000..9d3b75b9 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -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 + } + +} + diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift new file mode 100644 index 00000000..f44e1de8 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -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() + 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) + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift new file mode 100644 index 00000000..d59cb577 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -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 + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift new file mode 100644 index 00000000..b3a3eb41 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift @@ -0,0 +1,11 @@ +// +// MediaPreviewPagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Pageboy + +final class MediaPreviewPagingViewController: PageboyViewController { } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 1e10a632..83678cd5 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -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() var viewModel: FavoriteViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let titleView = DoubleTitleLabelNavigationBarTitleView() lazy var tableView: UITableView = { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 2ec350b0..d44dd744 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -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() var viewModel: UserTimelineViewModel! - // let mediaPreviewTransitionController = MediaPreviewTransitionController() + let mediaPreviewTransitionController = MediaPreviewTransitionController() lazy var tableView: UITableView = { let tableView = UITableView() diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 844c43cd..781d2ce1 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -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() var viewModel: PublicTimelineViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let refreshControl = UIRefreshControl() lazy var tableView: UITableView = { diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig new file mode 100644 index 00000000..c5ae31a8 --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig @@ -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() + + let context: AppContext + + // input + let setting: CurrentValueSubject + var updateDisposeBag = Set() + var createDisposeBag = Set() + + let viewDidLoad = PassthroughSubject() + + // output + var dataSource: UITableViewDiffableDataSource! + /// 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() + + // 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) + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index bd15b930..8ca8a339 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -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() var viewModel: ThreadViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let titleView = DoubleTitleLabelNavigationBarTitleView() let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem( diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift new file mode 100644 index 00000000..21142ad9 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -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 + } + +} + diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift new file mode 100644 index 00000000..83b008ee --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -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 + } + +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift new file mode 100644 index 00000000..de9f0fbf --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -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 + } + +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift new file mode 100644 index 00000000..7e4c39d0 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -0,0 +1,12 @@ +// +// MediaPreviewableViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import Foundation + +protocol MediaPreviewableViewController: class { + var mediaPreviewTransitionController: MediaPreviewTransitionController { get } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift new file mode 100644 index 00000000..1b92e512 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift @@ -0,0 +1,12 @@ +// +// MediaPreviewingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import Foundation + +protocol MediaPreviewingViewController: class { + func isInteractiveDismissable() -> Bool +} diff --git a/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift new file mode 100644 index 00000000..078bf656 --- /dev/null +++ b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift @@ -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 + } + +} diff --git a/Mastodon/Vender/TransitioningMath.swift b/Mastodon/Vender/TransitioningMath.swift new file mode 100644 index 00000000..6639b4dd --- /dev/null +++ b/Mastodon/Vender/TransitioningMath.swift @@ -0,0 +1,66 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt for this sample’s licensing information + + Abstract: + Convenience math operators + */ + +import QuartzCore + +func clip(_ x0: T, _ x1: T, _ v: T) -> T { + return max(x0, min(x1, v)) +} + +func lerp(_ 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 + } +} From 6f0b4354a7c87c8b4ef618ae76498723c9eb2160 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 19:06:45 +0800 Subject: [PATCH 02/13] feat: update preview present/dismiss transition style to pin-to-source rect --- .../StatusProvider/StatusProviderFacade.swift | 41 +++- .../MediaPreviewViewController.swift | 93 +++++++-- .../MediaPreview/MediaPreviewViewModel.swift | 8 +- .../Paging/Image/MediaPreviewImageView.swift | 5 +- .../Container/MosaicImageViewContainer.swift | 19 ++ .../ViewModel/MosaicImageViewModel.swift | 44 +++-- ...wViewControllerAnimatedTransitioning.swift | 178 +++++++++++++----- .../MediaPreviewTransitionController.swift | 7 +- .../MediaPreviewTransitionItem.swift | 27 ++- .../MediaPreviewableViewController.swift | 16 +- .../MediaPreviewingViewController.swift | 2 +- 11 files changed, 344 insertions(+), 96 deletions(-) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 8178ae95..d53e8e03 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -533,14 +533,51 @@ extension StatusProviderFacade { 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 } + guard let source = status else { return } + + let status = source.reblog ?? source let meta = MediaPreviewViewModel.StatusImagePreviewMeta( statusObjectID: status.objectID, initialIndex: index, preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } ) - let mediaPreviewViewModel = MediaPreviewViewModel(context: provider.context, meta: meta) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .mosaic(mosaicImageView), + previewableViewController: provider + ) + pushTransitionItem.aspectRatio = { + if let image = imageView.image { + return image.size + } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + let meta = media[index].meta + guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } + return CGSize(width: width, height: height) + }() + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = { + if let image = imageView.image { + return image + } + if index < mosaicImageView.blurhashOverlayImageViews.count { + return mosaicImageView.blurhashOverlayImageViews[index].image + } + + return nil + }() + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: provider.context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) DispatchQueue.main.async { provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController)) } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 8845fff8..9a515799 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -12,6 +12,8 @@ import Pageboy final class MediaPreviewViewController: UIViewController, NeedsDependency { + static let closeButtonSize = CGSize(width: 30, height: 30) + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -20,6 +22,23 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewConttroller = MediaPreviewPagingViewController() + + let closeButtonBackground: UIVisualEffectView = { + let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + backgroundView.alpha = 0.9 + backgroundView.layer.masksToBounds = true + backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5 + return backgroundView + }() + + let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) + + let closeButton: UIButton = { + let button = HitTestExpandedButton() + button.imageView?.tintColor = .label + button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) + return button + }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -48,11 +67,57 @@ extension MediaPreviewViewController { ]) pagingViewConttroller.didMove(toParent: self) + closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(closeButtonBackground) + NSLayoutConstraint.activate([ + closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12), + closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) + ]) + closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton) + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor), + closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor), + closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor), + closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor), + closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh), + closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh), + ]) + viewModel.mediaPreviewImageViewControllerDelegate = self pagingViewConttroller.interPageSpacing = 10 pagingViewConttroller.delegate = self pagingViewConttroller.dataSource = viewModel + + closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) + + // bind view model + viewModel.currentPage + .receive(on: DispatchQueue.main) + .sink { [weak self] index in + guard let self = self else { return } + switch self.viewModel.pushTransitionItem.source { + case .mosaic(let mosaicImageViewContainer): + UIView.animate(withDuration: 0.3) { + mosaicImageViewContainer.setImageViews(alpha: 1) + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } + } + } + .store(in: &disposeBag) + } + +} + +extension MediaPreviewViewController { + + @objc private func closeButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + dismiss(animated: true, completion: nil) } } @@ -61,20 +126,19 @@ extension MediaPreviewViewController { 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 + 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 } } @@ -107,6 +171,7 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { ) { // update page control // pageControl.currentPage = index + viewModel.currentPage.value = index } func pageboyViewController( diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 744eab44..2fe7f832 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -17,11 +17,13 @@ final class MediaPreviewViewModel: NSObject { let context: AppContext let initialItem: PreviewItem weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? - + let currentPage: CurrentValueSubject + // output + let pushTransitionItem: MediaPreviewTransitionItem let viewControllers: [UIViewController] - init(context: AppContext, meta: StatusImagePreviewMeta) { + init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { self.context = context self.initialItem = .status(meta) var viewControllers: [UIViewController] = [] @@ -45,6 +47,8 @@ final class MediaPreviewViewModel: NSObject { } } self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(meta.initialIndex) + self.pushTransitionItem = pushTransitionItem super.init() } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift index 9d3b75b9..0f2ba82f 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -45,7 +45,7 @@ extension MediaPreviewImageView { isUserInteractionEnabled = true showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false - + bouncesZoom = true minimumZoomScale = 1.0 maximumZoomScale = 4.0 @@ -139,6 +139,9 @@ extension MediaPreviewImageView: UIScrollViewDelegate { func scrollViewDidZoom(_ scrollView: UIScrollView) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) centerScrollViewContents() + + // set bounce when zoom in + alwaysBounceVertical = zoomScale > minimumZoomScale } func viewForZooming(in scrollView: UIScrollView) -> UIView? { diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 54e25ed8..bec55cd7 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -296,6 +296,25 @@ extension MosaicImageViewContainer { } +// FIXME: set imageView source from blurhash and image +extension MosaicImageViewContainer { + + func setImageViews(alpha: CGFloat) { + // blurhashOverlayImageViews.forEach { $0.alpha = alpha } + imageViews.forEach { $0.alpha = alpha } + } + + func setImageView(alpha: CGFloat, index: Int) { + // if index < blurhashOverlayImageViews.count { + // blurhashOverlayImageViews[index].alpha = alpha + // } + if index < imageViews.count { + imageViews[index].alpha = alpha + } + } + +} + extension MosaicImageViewContainer { @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index 26e426ad..c2ad3d4f 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -48,29 +48,35 @@ struct MosaicMeta { func blurhashImagePublisher() -> AnyPublisher { return Future { promise in - guard let blurhash = blurhash else { - promise(.success(nil)) - return - } - - let imageSize: CGSize = { - let aspectRadio = size.width / size.height - if size.width > size.height { - let width: CGFloat = MosaicMeta.edgeMaxLength - let height = width / aspectRadio - return CGSize(width: width, height: height) - } else { - let height: CGFloat = MosaicMeta.edgeMaxLength - let width = height * aspectRadio - return CGSize(width: width, height: height) - } - }() - workingQueue.async { - let image = UIImage(blurHash: blurhash, size: imageSize) + let image = self.blurhashImage() promise(.success(image)) } } .eraseToAnyPublisher() } + + func blurhashImage() -> UIImage? { + guard let blurhash = blurhash else { + return nil + } + + let imageSize: CGSize = { + let aspectRadio = size.width / size.height + if size.width > size.height { + let width: CGFloat = MosaicMeta.edgeMaxLength + let height = width / aspectRadio + return CGSize(width: width, height: height) + } else { + let height: CGFloat = MosaicMeta.edgeMaxLength + let width = height * aspectRadio + return CGSize(width: width, height: height) + } + }() + + let image = UIImage(blurHash: blurhash, size: imageSize) + + return image + } + } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 21142ad9..06678330 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -7,10 +7,10 @@ import os.log import UIKit +import func AVFoundation.AVMakeRect final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning { - let transitionItem: MediaPreviewTransitionItem let panGestureRecognizer: UIPanGestureRecognizer @@ -32,7 +32,6 @@ final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewCont } - // MARK: - UIViewControllerAnimatedTransitioning extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { @@ -51,19 +50,48 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { 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) + // set to image hidden + toVC.pagingViewConttroller.view.alpha = 0 + // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` + switch transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageView(alpha: 0, index: toVC.viewModel.currentPage.value) + } + + // Set transition image view + assert(transitionItem.initialFrame != nil) + let initialFrame = transitionItem.initialFrame ?? toViewEndFrame + let transitionTargetFrame: CGRect = { + let aspectRatio = transitionItem.aspectRatio ?? CGSize(width: initialFrame.width, height: initialFrame.height) + return AVMakeRect(aspectRatio: aspectRatio, insideRect: toView.bounds) + }() + let transitionImageView: UIImageView = { + let imageView = UIImageView(frame: transitionContext.containerView.convert(initialFrame, from: nil)) + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.image = transitionItem.image + return imageView + }() + transitionItem.targetFrame = transitionTargetFrame + transitionItem.imageView = transitionImageView + transitionContext.containerView.addSubview(transitionImageView) + + let animator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) animator.addAnimations { + transitionImageView.frame = transitionTargetFrame toView.alpha = 1 } animator.addCompletion { position in + toVC.pagingViewConttroller.view.alpha = 1 + transitionImageView.removeFromSuperview() transitionContext.completeTransition(position == .end) } @@ -72,17 +100,59 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { 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 { + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { fatalError() } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + fatalError() + } + mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView) + + snapshot.center = transitionContext.containerView.center - let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve) + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = targetFrame + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + + let animator = popInteractiveTransitionAnimator + + self.transitionItem.snapshotRaw?.alpha = 0.0 animator.addAnimations { - fromView.alpha = 0 + if let targetFrame = targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + fromView.alpha = 0 + } + fromVC.closeButtonBackground.alpha = 0 + fromVC.visualEffectView.effect = nil } animator.addCompletion { position in + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + switch self.transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageViews(alpha: 1) + } transitionContext.completeTransition(position == .end) } @@ -99,35 +169,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { 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 - + // Note: change item.imageView transform via pan gesture panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:))) popInteractiveTransition(using: transitionContext) default: @@ -138,27 +180,62 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let fromView = transitionContext.view(forKey: .from) else { + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { fatalError() } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + 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 = targetFrame + + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + 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.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil } animator.addCompletion { position in + fromVC.pagingViewConttroller.isUserInteractionEnabled = true + fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 self.transitionItem.imageView?.isHidden = position == .end self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 self.transitionItem.snapshotTransitioning?.removeFromSuperview() + if position == .end { + switch self.transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageViews(alpha: 1) + } + } fromVC.visualEffectView.effect = position == .end ? nil : blurEffect transitionContext.completeTransition(position == .end) } @@ -184,10 +261,10 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { 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) + targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() default: return } @@ -239,10 +316,17 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { itemAnimator.addAnimations { if toPosition == .end { - self.transitionItem.snapshotTransitioning?.alpha = 0 + if let targetFrame = self.transitionItem.targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } } else { - self.transitionItem.snapshotTransitioning?.alpha = 1 - self.transitionItem.snapshotTransitioning?.frame = self.transitionItem.initialFrame! + if let initialFrame = self.transitionItem.initialFrame { + self.transitionItem.snapshotTransitioning?.frame = initialFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 1 + } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index 83b008ee..c1de3023 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -74,10 +74,9 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat self.mediaPreviewViewController = mediaPreviewViewController self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer) - let transitionItem = MediaPreviewTransitionItem(id: UUID()) return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .push, - transitionItem: transitionItem, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, panGestureRecognizer: panGestureRecognizer ) } @@ -92,10 +91,10 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat assertionFailure() return nil } - let transitionItem = MediaPreviewTransitionItem(id: UUID()) + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .pop, - transitionItem: transitionItem, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, panGestureRecognizer: panGestureRecognizer ) } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index de9f0fbf..73afeeb8 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -6,21 +6,40 @@ // import UIKit +import CoreData class MediaPreviewTransitionItem: Identifiable { let id: UUID + let source: Source + var previewableViewController: MediaPreviewableViewController - // TODO: + // source + // value maybe invalid when preview paging + var image: UIImage? + var aspectRatio: CGSize? + var initialFrame: CGRect? = nil + var sourceImageView: UIImageView? + + // target + var targetFrame: CGRect? = nil + + // transitioning 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) { + init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { self.id = id + self.source = source + self.previewableViewController = previewableViewController } } + +extension MediaPreviewTransitionItem { + enum Source { + case mosaic(MosaicImageViewContainer) + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 7e4c39d0..e5eb8ba6 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -5,8 +5,20 @@ // Created by MainasuK Cirno on 2021-4-28. // -import Foundation +import UIKit -protocol MediaPreviewableViewController: class { +protocol MediaPreviewableViewController: AnyObject { var mediaPreviewTransitionController: MediaPreviewTransitionController { get } + func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? +} + +extension MediaPreviewableViewController { + func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? { + switch transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + guard index < mosaicImageViewContainer.imageViews.count else { return nil } + let imageView = mosaicImageViewContainer.imageViews[index] + return imageView.superview!.convert(imageView.frame, to: nil) + } + } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift index 1b92e512..9c6e56d0 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift @@ -7,6 +7,6 @@ import Foundation -protocol MediaPreviewingViewController: class { +protocol MediaPreviewingViewController: AnyObject { func isInteractiveDismissable() -> Bool } From acbbafb18f4ff1d54aa46f5f663402c1fd89f834 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 20:10:17 +0800 Subject: [PATCH 03/13] feat: handle profile avatar preview --- .../Protocol/AvatarConfigurableView.swift | 17 +++++++-- .../MediaPreviewViewController.swift | 2 ++ .../MediaPreview/MediaPreviewViewModel.swift | 28 ++++++++++++++- .../Image/MediaPreviewImageViewModel.swift | 6 ++-- .../Header/ProfileHeaderViewController.swift | 3 +- .../Header/View/ProfileHeaderView.swift | 32 ++++++++++++++++- .../Scene/Profile/ProfileViewController.swift | 36 ++++++++++++++++++- ...wViewControllerAnimatedTransitioning.swift | 18 ++++------ .../MediaPreviewTransitionItem.swift | 16 +++++++++ .../MediaPreviewableViewController.swift | 2 ++ 10 files changed, 138 insertions(+), 22 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 1c2c78da..f2e95491 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -51,7 +51,10 @@ extension AvatarConfigurableView { avatarConfigurableView(self, didFinishConfiguration: configuration) } - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) + let filter = ScaledToSizeWithRoundedCornersFilter( + size: Self.configurableAvatarImageSize, + radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius + ) // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { @@ -91,6 +94,12 @@ extension AvatarConfigurableView { runImageTransitionIfCached: false, completion: nil ) + + if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner { + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + } } configureLayerBorder(view: avatarImageView, configuration: configuration) @@ -148,16 +157,20 @@ struct AvatarConfigurableViewConfiguration { let borderColor: UIColor? let borderWidth: CGFloat? + let keepImageCorner: Bool + init( avatarImageURL: URL?, placeholderImage: UIImage? = nil, borderColor: UIColor? = nil, - borderWidth: CGFloat? = nil + borderWidth: CGFloat? = nil, + keepImageCorner: Bool = true ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage self.borderColor = borderColor self.borderWidth = borderWidth + self.keepImageCorner = keepImageCorner } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 9a515799..01d33853 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -106,6 +106,8 @@ extension MediaPreviewViewController { mosaicImageViewContainer.setImageViews(alpha: 1) mosaicImageViewContainer.setImageView(alpha: 0, index: index) } + case .profileAvatar: + break } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 2fe7f832..6a0b749b 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -36,7 +36,7 @@ final class MediaPreviewViewModel: NSObject { switch entity.type { case .image: guard let url = URL(string: entity.url) else { continue } - let meta = MediaPreviewImageViewModel.StatusImagePreviewMeta(url: url, thumbnail: thumbnail) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel @@ -52,12 +52,33 @@ final class MediaPreviewViewModel: NSObject { super.init() } + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileAvatar(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.avatarImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + } extension MediaPreviewViewModel { enum PreviewItem { case status(StatusImagePreviewMeta) + case profileAvatar(ProfileAvatarImagePreviewMeta) case local(LocalImagePreviewMeta) } @@ -67,6 +88,11 @@ extension MediaPreviewViewModel { let preloadThumbnailImages: [UIImage?] } + struct ProfileAvatarImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + struct LocalImagePreviewMeta { let image: UIImage } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index d59cb577..0ba7d4dc 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -13,7 +13,7 @@ class MediaPreviewImageViewModel { // input let item: ImagePreviewItem - init(meta: StatusImagePreviewMeta) { + init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) } @@ -25,11 +25,11 @@ class MediaPreviewImageViewModel { extension MediaPreviewImageViewModel { enum ImagePreviewItem { - case status(StatusImagePreviewMeta) + case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) } - struct StatusImagePreviewMeta { + struct RemoteImagePreviewMeta { let url: URL let thumbnail: UIImage? } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 38695f34..bb89dab0 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -150,8 +150,7 @@ extension ProfileHeaderViewController { with: AvatarConfigurableViewConfiguration( avatarImageURL: image == nil ? url : nil, // set only when image empty placeholderImage: image, - borderColor: .white, - borderWidth: 2 + keepImageCorner: true // fit preview transitioning ) ) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 09d99c51..1f1f6b71 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,7 +10,8 @@ import UIKit import ActiveLabel import TwitterTextEditor -protocol ProfileHeaderViewDelegate: class { +protocol ProfileHeaderViewDelegate: AnyObject { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) @@ -23,6 +24,8 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 + static let avatarImageViewBorderColor = UIColor.white + static let avatarImageViewBorderWidth: CGFloat = 2 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) static let bannerImageViewPlaceholderColor = UIColor.systemGray @@ -51,6 +54,16 @@ final class ProfileHeaderView: UIView { return overlayView }() + let avatarImageViewBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = ProfileHeaderView.avatarImageViewBorderColor.cgColor + view.layer.borderWidth = ProfileHeaderView.avatarImageViewBorderWidth + return view + }() + let avatarImageView: UIImageView = { let imageView = UIImageView() let placeholderImage = UIImage @@ -188,6 +201,15 @@ extension ProfileHeaderView { avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) + avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + ]) + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.addSubview(editAvatarBackgroundView) NSLayoutConstraint.activate([ @@ -313,6 +335,9 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self + let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) + avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) @@ -372,6 +397,11 @@ extension ProfileHeaderView { assert(sender === relationshipActionButton) delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) } + + @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) + } } // MARK: - ActiveLabelDelegate diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index e4be1eb1..ff8c38ed 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -10,7 +10,7 @@ import UIKit import Combine import ActiveLabel -final class ProfileViewController: UIViewController, NeedsDependency { +final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -18,6 +18,8 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) barButtonItem.tintColor = .white @@ -645,6 +647,38 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) { + guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let avatar = imageView.image else { return } + + let meta = MediaPreviewViewModel.ProfileAvatarImagePreviewMeta( + accountObjectID: mastodonUser.objectID, + preloadThumbnailImage: avatar + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .profileAvatar(profileHeaderView), + previewableViewController: self + ) + pushTransitionItem.aspectRatio = CGSize(width: 100, height: 100) + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.sourceImageViewCornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = avatar + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 06678330..1c54fadf 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -58,10 +58,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { // set to image hidden toVC.pagingViewConttroller.view.alpha = 0 // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` - switch transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageView(alpha: 0, index: toVC.viewModel.currentPage.value) - } + transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value) // Set transition image view assert(transitionItem.initialFrame != nil) @@ -143,16 +140,14 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } else { fromView.alpha = 0 } + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil } animator.addCompletion { position in self.transitionItem.snapshotTransitioning?.removeFromSuperview() - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + self.transitionItem.source.updateAppearance(position: position, index: nil) transitionContext.completeTransition(position == .end) } @@ -222,6 +217,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { animator.addAnimations { fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } } animator.addCompletion { position in @@ -231,10 +227,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 self.transitionItem.snapshotTransitioning?.removeFromSuperview() if position == .end { - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) } fromVC.visualEffectView.effect = position == .end ? nil : blurEffect transitionContext.completeTransition(position == .end) diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 73afeeb8..48533cf3 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -20,6 +20,7 @@ class MediaPreviewTransitionItem: Identifiable { var aspectRatio: CGSize? var initialFrame: CGRect? = nil var sourceImageView: UIImageView? + var sourceImageViewCornerRadius: CGFloat? // target var targetFrame: CGRect? = nil @@ -41,5 +42,20 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { case mosaic(MosaicImageViewContainer) + case profileAvatar(ProfileHeaderView) + + func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { + let alpha: CGFloat = position == .end ? 1 : 0 + switch self { + case .mosaic(let mosaicImageViewContainer): + if let index = index { + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } else { + mosaicImageViewContainer.setImageViews(alpha: alpha) + } + case .profileAvatar(let profileHeaderView): + profileHeaderView.avatarImageView.alpha = alpha + } + } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index e5eb8ba6..c7080b21 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -19,6 +19,8 @@ extension MediaPreviewableViewController { guard index < mosaicImageViewContainer.imageViews.count else { return nil } let imageView = mosaicImageViewContainer.imageViews[index] return imageView.superview!.convert(imageView.frame, to: nil) + case .profileAvatar(let profileHeaderView): + return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) } } } From 7a13b39c5111470220a76401e89a30af01821fb8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 20:36:10 +0800 Subject: [PATCH 04/13] feat: handle profile banner preview --- .../Protocol/AvatarConfigurableView.swift | 2 +- .../MediaPreviewViewController.swift | 2 +- .../MediaPreview/MediaPreviewViewModel.swift | 26 ++++++++++++++++ .../Header/View/ProfileHeaderView.swift | 12 +++++++ .../Scene/Profile/ProfileViewController.swift | 31 +++++++++++++++++++ ...wViewControllerAnimatedTransitioning.swift | 21 ++++++++++--- .../MediaPreviewTransitionItem.swift | 3 ++ .../MediaPreviewableViewController.swift | 2 ++ 8 files changed, 93 insertions(+), 6 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index f2e95491..3d2dba80 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -164,7 +164,7 @@ struct AvatarConfigurableViewConfiguration { placeholderImage: UIImage? = nil, borderColor: UIColor? = nil, borderWidth: CGFloat? = nil, - keepImageCorner: Bool = true + keepImageCorner: Bool = false // default clip corner on image ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 01d33853..6feaaff4 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -106,7 +106,7 @@ extension MediaPreviewViewController { mosaicImageViewContainer.setImageViews(alpha: 1) mosaicImageViewContainer.setImageView(alpha: 0, index: index) } - case .profileAvatar: + case .profileAvatar, .profileBanner: break } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 6a0b749b..1b0cc6de 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -52,6 +52,26 @@ final class MediaPreviewViewModel: NSObject { super.init() } + init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileBanner(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.headerImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { self.context = context self.initialItem = .profileAvatar(meta) @@ -79,6 +99,7 @@ extension MediaPreviewViewModel { enum PreviewItem { case status(StatusImagePreviewMeta) case profileAvatar(ProfileAvatarImagePreviewMeta) + case profileBanner(ProfileBannerImagePreviewMeta) case local(LocalImagePreviewMeta) } @@ -93,6 +114,11 @@ extension MediaPreviewViewModel { let preloadThumbnailImage: UIImage? } + struct ProfileBannerImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + struct LocalImagePreviewMeta { let image: UIImage } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 1f1f6b71..d5fcc5c4 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -12,6 +12,7 @@ import TwitterTextEditor protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) @@ -43,6 +44,7 @@ final class ProfileHeaderView: UIView { imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor imageView.layer.masksToBounds = true + imageView.isUserInteractionEnabled = true // #if DEBUG // imageView.image = .placeholder(color: .red) // #endif @@ -338,6 +340,11 @@ extension ProfileHeaderView { let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) + + let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + bannerImageView.addGestureRecognizer(bannerImageViewSingleTapGestureRecognizer) + bannerImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.bannerImageViewDidPressed(_:))) + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) @@ -402,6 +409,11 @@ extension ProfileHeaderView { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) } + + @objc private func bannerImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, bannerImageViewDidPressed: bannerImageView) + } } // MARK: - ActiveLabelDelegate diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index ff8c38ed..7df3bc23 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -679,6 +679,37 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let header = imageView.image else { return } + + let meta = MediaPreviewViewModel.ProfileBannerImagePreviewMeta( + accountObjectID: mastodonUser.objectID, + preloadThumbnailImage: header + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .profileBanner(profileHeaderView), + previewableViewController: self + ) + pushTransitionItem.aspectRatio = header.size + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = header + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 1c54fadf..a0bf523f 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -204,7 +204,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { transitionItem.imageView = imageView transitionItem.snapshotTransitioning = snapshot transitionItem.initialFrame = snapshot.frame - transitionItem.targetFrame = targetFrame + transitionItem.targetFrame = targetFrame ?? snapshot.frame // disable interaction fromVC.pagingViewConttroller.isUserInteractionEnabled = false @@ -215,6 +215,12 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { self.transitionItem.snapshotRaw?.alpha = 0.0 animator.addAnimations { + switch self.transitionItem.source { + case .profileBanner: + self.transitionItem.snapshotTransitioning?.alpha = 0.4 + default: + break + } fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } @@ -310,11 +316,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { itemAnimator.addAnimations { if toPosition == .end { - if let targetFrame = self.transitionItem.targetFrame { - self.transitionItem.snapshotTransitioning?.frame = targetFrame - } else { + switch self.transitionItem.source { + case .profileBanner where toPosition == .end: + // fade transition for banner self.transitionItem.snapshotTransitioning?.alpha = 0 + default: + if let targetFrame = self.transitionItem.targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } } + } else { if let initialFrame = self.transitionItem.initialFrame { self.transitionItem.snapshotTransitioning?.frame = initialFrame diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 48533cf3..47fdd215 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -43,6 +43,7 @@ extension MediaPreviewTransitionItem { enum Source { case mosaic(MosaicImageViewContainer) case profileAvatar(ProfileHeaderView) + case profileBanner(ProfileHeaderView) func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { let alpha: CGFloat = position == .end ? 1 : 0 @@ -55,6 +56,8 @@ extension MediaPreviewTransitionItem { } case .profileAvatar(let profileHeaderView): profileHeaderView.avatarImageView.alpha = alpha + case .profileBanner: + break // keep source } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index c7080b21..8029c09d 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -21,6 +21,8 @@ extension MediaPreviewableViewController { return imageView.superview!.convert(imageView.frame, to: nil) case .profileAvatar(let profileHeaderView): return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) + case .profileBanner: + return nil // fallback to snapshot.frame } } } From 2fd10eab8433f46b74f5aa72cb9cce8f29da6166 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 16:03:44 +0800 Subject: [PATCH 05/13] fix: avatar not using inner border issue --- .../Scene/Profile/Header/View/ProfileHeaderView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index d5fcc5c4..1e09116d 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -206,10 +206,10 @@ extension ProfileHeaderView { avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) NSLayoutConstraint.activate([ - avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), ]) editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false From 4b96ac4481a292476bbacfbae0dbcb52fbe5c9e5 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 17:05:35 +0800 Subject: [PATCH 06/13] fix: disallow header preview when editing profile --- Mastodon/Scene/Profile/ProfileViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 7df3bc23..17d0f6b8 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -680,6 +680,9 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + // not preview header banner when editing + guard !viewModel.isEditing.value else { return } + guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let header = imageView.image else { return } From df2a73d96c9ad4480620b9a9374a725b3ea56172 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 17:13:13 +0800 Subject: [PATCH 07/13] fix: profile title view not align center issue. resolve #117. --- .../Profile/Header/ProfileHeaderViewController.swift | 5 ++++- .../Scene/Profile/Header/ProfileHeaderViewModel.swift | 1 + Mastodon/Scene/Profile/ProfileViewController.swift | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index bb89dab0..3949c328 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -328,7 +328,9 @@ extension ProfileHeaderViewController { let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset - titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset)) + let transformY = max(0, titleViewContentOffset) + titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) + viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height if viewModel.viewDidAppear.value { viewModel.isTitleViewContentOffsetSet.value = true @@ -347,6 +349,7 @@ extension ProfileHeaderViewController { } private func setProfileBannerFade(alpha: CGFloat) { + profileHeaderView.avatarImageViewBackgroundView.alpha = alpha profileHeaderView.avatarImageView.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha profileHeaderView.nameTextFieldBackgroundView.alpha = alpha diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index eb4a054b..6e4fe2de 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -24,6 +24,7 @@ final class ProfileHeaderViewModel { // output let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() + let isTitleViewDisplaying = CurrentValueSubject(false) init(context: AppContext) { self.context = context diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 17d0f6b8..d826cdaa 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -172,13 +172,14 @@ extension ProfileViewController { } .store(in: &disposeBag) - Publishers.CombineLatest3 ( + Publishers.CombineLatest4 ( viewModel.suspended.eraseToAnyPublisher(), + profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), editingAndUpdatingPublisher.eraseToAnyPublisher(), barButtonItemHiddenPublisher.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] suspended, tuple1, tuple2 in + .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in guard let self = self else { return } let (isEditing, _) = tuple1 let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 @@ -197,6 +198,10 @@ extension ProfileViewController { return } + guard !isTitleViewDisplaying else { + return + } + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) From aace886401c2dfc5b73ff4962abf943d1f515974 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 19:49:46 +0800 Subject: [PATCH 08/13] feat: add save photo action for image preview scene --- Mastodon.xcodeproj/project.pbxproj | 4 ++ .../xcschemes/xcschememanagement.plist | 4 +- .../MediaPreviewViewController.swift | 33 ++++++++- .../MediaPreviewImageViewController.swift | 53 +++++++++++++- .../Image/MediaPreviewImageViewModel.swift | 13 ++++ Mastodon/Service/PhotoLibraryService.swift | 69 +++++++++++++++++++ Mastodon/State/AppContext.swift | 3 +- 7 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 Mastodon/Service/PhotoLibraryService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 80ce2d0e..428a9916 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -380,6 +380,7 @@ DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; @@ -935,6 +936,7 @@ DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1289,6 +1291,7 @@ DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, DB6D9F6226357848008423CD /* SettingService.swift */, + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, ); path = Service; sourceTree = ""; @@ -2864,6 +2867,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 32685726..8ec70bb4 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 18 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 15 + 17 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 6feaaff4..b57be0fb 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -19,7 +19,7 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: MediaPreviewViewModel! - + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewConttroller = MediaPreviewPagingViewController() @@ -191,11 +191,38 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { - + // do nothing } func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { - // delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer) + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) { + switch action { + case .savePhoto: + switch viewController.viewModel.item { + case .status(let meta): + context.photoLibraryService.saveImage(url: meta.url).sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &context.disposeBag) + case .local(let meta): + context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) + } + case .share: + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: viewController.viewModel.item.activityItems, + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView + self.present(activityViewController, animated: true, completion: nil) + } } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index f44e1de8..ac4a4c96 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -9,9 +9,10 @@ import os.log import UIKit import Combine -protocol MediaPreviewImageViewControllerDelegate: class { +protocol MediaPreviewImageViewControllerDelegate: AnyObject { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) } final class MediaPreviewImageViewController: UIViewController { @@ -63,6 +64,9 @@ extension MediaPreviewImageViewController { previewImageView.addGestureRecognizer(tapGestureRecognizer) previewImageView.addGestureRecognizer(longPressGestureRecognizer) + let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) + previewImageView.addInteraction(previewImageViewContextMenuInteraction) + switch viewModel.item { case .status(let meta): // progressBarView.isHidden = meta.thumbnail != nil @@ -113,3 +117,50 @@ extension MediaPreviewImageViewController { } } + +// MARK: - UIContextMenuInteractionDelegate +extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in + return nil + } + + let saveAction = UIAction( + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) + } + + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) + } + + let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + saveAction, + shareAction + ]) + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // set preview view + return UITargetedPreview(view: previewImageView.imageView) + } + +} + +extension MediaPreviewImageViewController { + enum ContextMenuAction { + case savePhoto + case share + } +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 0ba7d4dc..9215ef61 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -27,6 +27,19 @@ extension MediaPreviewImageViewModel { enum ImagePreviewItem { case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) + + var activityItems: [Any] { + var items: [Any] = [] + + switch self { + case .status(let meta): + items.append(meta.url) + case .local(let meta): + items.append(meta.image) + } + + return items + } } struct RemoteImagePreviewMeta { diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift new file mode 100644 index 00000000..44918eb5 --- /dev/null +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -0,0 +1,69 @@ +// +// PhotoLibraryService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-29. +// + +import os.log +import UIKit +import Combine +import AlamofireImage + +final class PhotoLibraryService: NSObject { + +} + +extension PhotoLibraryService { + + func saveImage(url: URL) -> AnyPublisher { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return Future { promise in + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + promise(.failure(error)) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.save(image: image) + promise(.success(image)) + } + }) + } + .handleEvents(receiveSubscription: { _ in + impactFeedbackGenerator.impactOccurred() + }, receiveCompletion: { completion in + switch completion { + case .failure: + notificationFeedbackGenerator.notificationOccurred(.error) + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + } + }) + .eraseToAnyPublisher() + } + + func save(image: UIImage, withNotificationFeedback: Bool = false) { + UIImageWriteToSavedPhotosAlbum( + image, + self, + #selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)), + nil + ) + + // assert no error + if withNotificationFeedback { + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + notificationFeedbackGenerator.notificationOccurred(.success) + } + } + + @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + // TODO: notify banner + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 93287f6e..7a74de8b 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -30,7 +30,8 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService - + let photoLibraryService = PhotoLibraryService() + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! From aceaa618e058329256950212c52a5a3fc1bdea33 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:28:06 +0800 Subject: [PATCH 09/13] feat: add context menu for post image --- Mastodon.xcodeproj/project.pbxproj | 32 +++ .../xcschemes/xcschememanagement.plist | 4 +- .../Diffiable/Section/StatusSection.swift | 4 +- .../StatusProvider+UITableViewDelegate.swift | 250 +++++++++++++++++- .../StatusProvider/StatusProviderFacade.swift | 2 +- .../StatusTableViewControllerAspect.swift | 44 ++- .../HashtagTimelineViewController.swift | 17 ++ .../HomeTimelineViewController.swift | 17 ++ .../MediaPreviewViewController.swift | 18 +- .../MediaPreviewImageViewController.swift | 52 ++-- .../Image/MediaPreviewImageViewModel.swift | 19 ++ .../Favorite/FavoriteViewController.swift | 16 ++ .../Timeline/UserTimelineViewController.swift | 16 ++ ...ontextMenuImagePreviewViewController.swift | 61 +++++ .../ContextMenuImagePreviewViewModel.swift | 25 ++ ...ableViewCellContextMenuConfiguration.swift | 16 ++ .../Container/MosaicImageViewContainer.swift | 24 +- .../Scene/Share/View/Content/StatusView.swift | 4 + .../TableviewCell/StatusTableViewCell.swift | 19 +- .../Scene/Thread/ThreadViewController.swift | 16 ++ ...wViewControllerAnimatedTransitioning.swift | 3 +- .../MediaPreviewTransitionController.swift | 2 +- Mastodon/Vender/CustomScheduler.swift | 50 ++++ 23 files changed, 647 insertions(+), 64 deletions(-) create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift create mode 100644 Mastodon/Vender/CustomScheduler.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 428a9916..02ff93cc 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; @@ -381,6 +382,9 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; }; + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; }; + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; @@ -869,6 +873,7 @@ DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; @@ -937,6 +942,9 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = ""; }; + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1272,6 +1280,7 @@ DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, ); path = Vender; sourceTree = ""; @@ -1372,6 +1381,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, + DBA5E7A6263BD298004598BB /* ContextMenu */, ); path = Share; sourceTree = ""; @@ -2201,6 +2211,24 @@ path = Helper; sourceTree = ""; }; + DBA5E7A6263BD298004598BB /* ContextMenu */ = { + isa = PBXGroup; + children = ( + DBA5E7A7263BD29F004598BB /* ImagePreview */, + ); + path = ContextMenu; + sourceTree = ""; + }; + DBA5E7A7263BD29F004598BB /* ImagePreview */ = { + isa = PBXGroup; + children = ( + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */, + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */, + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */, + ); + path = ImagePreview; + sourceTree = ""; + }; DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( @@ -2883,6 +2911,7 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -2943,6 +2972,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, @@ -2957,6 +2987,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -3036,6 +3067,7 @@ DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 8ec70bb4..32685726 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 18 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b897de47..519637b8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -12,7 +12,7 @@ import os.log import UIKit import AVKit -protocol StatusCell : DisposeBagCollectable { +protocol StatusCell: DisposeBagCollectable { var statusView: StatusView { get } var pollCountdownSubscription: AnyCancellable? { get set } } @@ -142,7 +142,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index cd6cbf58..ef19abab 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -106,4 +106,252 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } -extension StatusTableViewCellDelegate where Self: StatusProvider {} +extension StatusTableViewCellDelegate where Self: StatusProvider { + + private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable + + func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil } + guard imagePreviewPresentableCell.isRevealing else { return nil } + + let status = status(for: nil, indexPath: indexPath) + + return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + private func contextMenuConfiguration( + _ tableView: UITableView, + status: Future, + imagePreviewPresentableCell presentable: ImagePreviewPresentableCell, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + let imageViews = presentable.mosaicImageViewContainer.imageViews + guard !imageViews.isEmpty else { return nil } + + for (i, imageView) in imageViews.enumerated() { + let pointInImageView = imageView.convert(point, from: tableView) + guard imageView.point(inside: pointInImageView, with: nil) else { + continue + } + guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else { + // not provide preview until image ready + return nil + + } + // setup preview + let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image) + status + .sink { status in + guard let status = (status?.reblog ?? status), + let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }), + i < media.count, let url = URL(string: media[i].url) else { + return + } + + contextMenuImagePreviewViewModel.url.value = url + } + .store(in: &contextMenuImagePreviewViewModel.disposeBag) + + // setup context menu + let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in + // know issue: preview size looks not as large as system default preview + let previewProvider = ContextMenuImagePreviewViewController() + previewProvider.viewModel = contextMenuImagePreviewViewModel + return previewProvider + } actionProvider: { _ -> UIMenu? in + let savePhotoAction = UIAction( + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.attachment(of: status, index: i) + .setFailureType(to: Error.self) + .compactMap { attachment -> AnyPublisher? in + guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } + return self.context.photoLibraryService.saveImage(url: url) + } + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { _ in + // do nothing + }) + .store(in: &self.context.disposeBag) + } + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.attachment(of: status, index: i) + .sink(receiveValue: { [weak self] attachment in + guard let self = self else { return } + guard let attachment = attachment, let url = URL(string: attachment.url) else { return } + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [url], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = imageView + self.present(activityViewController, animated: true, completion: nil) + }) + .store(in: &self.context.disposeBag) + } + let children = [savePhotoAction, shareAction] + return UIMenu(title: "", image: nil, children: children) + } + contextMenuConfiguration.indexPath = indexPath + contextMenuConfiguration.index = i + return contextMenuConfiguration + } + + return nil + } + + private func attachment(of status: Future, index: Int) -> AnyPublisher { + status + .map { status in + guard let status = status?.reblog ?? status else { return nil } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + return media[index] + } + .eraseToAnyPublisher() + } + + func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { + return nil + } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return nil } + let imageView = imageViews[index] + return UITargetedPreview(view: imageView, parameters: UIPreviewParameters()) + } + + func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + guard let previewableViewController = self as? MediaPreviewableViewController else { return } + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return } + let imageView = imageViews[index] + + let status = status(for: nil, indexPath: indexPath) + let initialFrame: CGRect? = { + guard let previewViewController = animator.previewViewController else { return nil } + return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController) + }() + animator.preferredCommitStyle = .pop + animator.addCompletion { [weak self] in + guard let self = self else { return } + status + //.delay(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] status in + guard let self = self else { return } + guard let status = (status?.reblog ?? status) else { return } + + let meta = MediaPreviewViewModel.StatusImagePreviewMeta( + statusObjectID: status.objectID, + initialIndex: index, + preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails() + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .mosaic(cell.mosaicImageViewContainer), + previewableViewController: previewableViewController + ) + pushTransitionItem.aspectRatio = { + if let image = imageView.image { + return image.size + } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + let meta = media[index].meta + guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } + return CGSize(width: width, height: height) + }() + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + if let initialFrame = initialFrame { + return initialFrame + } + return imageView.superview!.convert(imageView.frame, to: nil) + }() + pushTransitionItem.image = { + if let image = imageView.image { + return image + } + if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count { + return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image + } + + return nil + }() + let mediaPreviewViewModel = MediaPreviewViewModel( + context: self.context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController)) + } + } + .store(in: &cell.disposeBag) + } + } + + + + +} + +extension UIView { + + // hack to retrieve preview view frame in window + fileprivate static func findContextMenuPreviewFrameInWindow( + previewController: UIViewController + ) -> CGRect? { + guard let window = previewController.view.window else { return nil } + + let targetViews = window.subviews + .map { $0.findSameSize(view: previewController.view) } + .flatMap { $0 } + for targetView in targetViews { + guard let targetViewSuperview = targetView.superview else { continue } + let frame = targetViewSuperview.convert(targetView.frame, to: nil) + guard frame.origin.x > 0, frame.origin.y > 0 else { continue } + return frame + } + + return nil + } + + private func findSameSize(view: UIView) -> [UIView] { + var views: [UIView] = [] + + if view.bounds.size == bounds.size { + views.append(self) + } + + for subview in subviews { + let targetViews = subview.findSameSize(view: view) + views.append(contentsOf: targetViews) + } + + return views + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index d53e8e03..56e9d474 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -540,7 +540,7 @@ extension StatusProviderFacade { let meta = MediaPreviewViewModel.StatusImagePreviewMeta( statusObjectID: status.objectID, initialIndex: index, - preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } + preloadThumbnailImages: mosaicImageView.thumbnails() ) let pushTransitionItem = MediaPreviewTransitionItem( source: .mosaic(mosaicImageView), diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index f96998ea..e418569c 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -9,12 +9,12 @@ import UIKit import AVKit // Check List Last Updated -// - HomeViewController: 2021/4/13 -// - FavoriteViewController: 2021/4/14 -// - HashtagTimelineViewController: 2021/4/8 -// - UserTimelineViewController: 2021/4/13 -// - ThreadViewController: 2021/4/13 -// * StatusTableViewControllerAspect: 2021/4/12 +// - HomeViewController: 2021/4/30 +// - FavoriteViewController: 2021/4/30 +// - HashtagTimelineViewController: 2021/4/30 +// - UserTimelineViewController: 2021/4/30 +// - ThreadViewController: 2021/4/30 +// * StatusTableViewControllerAspect: 2021/4/30 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -103,6 +103,38 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } +// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to display context menu for images + func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } +} + +// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } +} + +// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } +} + +// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu preview action + func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } +} + // MARK: - UITableViewDataSourcePrefetching [C] // [C1] aspectTableView(:prefetchRowsAt) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 7a340473..638aa766 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -224,6 +224,23 @@ extension HashtagTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 8932346e..6db1c26f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -378,6 +378,23 @@ extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index b57be0fb..5684a6ba 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -137,9 +137,12 @@ extension MediaPreviewViewController: MediaPreviewingViewController { let safeAreaInsets = previewImageView.safeAreaInsets let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 - return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false") + return dismissable } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function) return false } @@ -203,12 +206,13 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { case .savePhoto: switch viewController.viewModel.item { case .status(let meta): - context.photoLibraryService.saveImage(url: meta.url).sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &context.disposeBag) + context.photoLibraryService.saveImage(url: meta.url) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &context.disposeBag) case .local(let meta): context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index ac4a4c96..7ac3c202 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -18,6 +18,8 @@ protocol MediaPreviewImageViewControllerDelegate: AnyObject { final class MediaPreviewImageViewController: UIViewController { var disposeBag = Set() + var observations = Set() + var viewModel: MediaPreviewImageViewModel! weak var delegate: MediaPreviewImageViewControllerDelegate? @@ -56,7 +58,7 @@ extension MediaPreviewImageViewController { 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) @@ -67,39 +69,15 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) - 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) - } + viewModel.image + .receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state) + .sink { [weak self] image in + guard let self = self else { return } + guard let image = image else { return } + self.previewImageView.imageView.image = image + self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) + } + .store(in: &disposeBag) } } @@ -128,14 +106,16 @@ extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { } let saveAction = UIAction( - title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) guard let self = self else { return } self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) } let shareAction = UIAction( - title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) guard let self = self else { return } self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 9215ef61..6be61dfc 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -5,20 +5,39 @@ // Created by MainasuK Cirno on 2021-4-28. // +import os.log import UIKit import Combine +import AlamofireImage class MediaPreviewImageViewModel { // input let item: ImagePreviewItem + + // output + let image: CurrentValueSubject init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) + self.image = CurrentValueSubject(meta.thumbnail) + + let url = meta.url + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.image.value = image + } + }) } init(meta: LocalImagePreviewMeta) { self.item = .local(meta) + self.image = CurrentValueSubject(meta.image) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 83678cd5..01d76f4b 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -120,6 +120,22 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index d44dd744..503ce04c 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -128,6 +128,22 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift new file mode 100644 index 00000000..2a5ba492 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -0,0 +1,61 @@ +// +// ContextMenuImagePreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import func AVFoundation.AVMakeRect +import UIKit +import Combine + +final class ContextMenuImagePreviewViewController: UIViewController { + + var disposeBag = Set() + + var viewModel: ContextMenuImagePreviewViewModel! + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + +} + +extension ContextMenuImagePreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + imageView.image = viewModel.thumbnail + + let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds) + preferredContentSize = frame.size + + viewModel.url + .sink { [weak self] url in + guard let self = self else { return } + guard let url = url else { return } + self.imageView.af.setImage( + withURL: url, + placeholderImage: self.viewModel.thumbnail, + imageTransition: .crossDissolve(0.2), + runImageTransitionIfCached: true, + completion: nil + ) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift new file mode 100644 index 00000000..f56ff060 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift @@ -0,0 +1,25 @@ +// +// ContextMenuImagePreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit +import Combine + +final class ContextMenuImagePreviewViewModel { + + var disposeBag = Set() + + // input + let aspectRatio: CGSize + let thumbnail: UIImage? + let url = CurrentValueSubject(nil) + + init(aspectRatio: CGSize, thumbnail: UIImage?) { + self.aspectRatio = aspectRatio + self.thumbnail = thumbnail + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift new file mode 100644 index 00000000..e8e7787f --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift @@ -0,0 +1,16 @@ +// +// TimelineTableViewCellContextMenuConfiguration.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit + +// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue +final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration { + + var indexPath: IndexPath? + var index: Int? + +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index bec55cd7..ea943fb0 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -9,14 +9,14 @@ import os.log import func AVFoundation.AVMakeRect import UIKit -protocol MosaicImageViewContainerPresentable: class { +protocol MosaicImageViewContainerPresentable: AnyObject { var mosaicImageViewContainer: MosaicImageViewContainer { get } + var isRevealing: Bool { get } } -protocol MosaicImageViewContainerDelegate: class { +protocol MosaicImageViewContainerDelegate: AnyObject { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - } final class MosaicImageViewContainer: UIView { @@ -296,7 +296,7 @@ extension MosaicImageViewContainer { } -// FIXME: set imageView source from blurhash and image +// FIXME: refactor blurhash image and preview image extension MosaicImageViewContainer { func setImageViews(alpha: CGFloat) { @@ -313,6 +313,22 @@ extension MosaicImageViewContainer { } } + func thumbnail(at index: Int) -> UIImage? { + guard blurhashOverlayImageViews.count == imageViews.count else { return nil } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + guard index < tuples.count else { return nil } + let tuple = tuples[index] + return tuple.1.image ?? tuple.0.image + } + + func thumbnails() -> [UIImage?] { + guard blurhashOverlayImageViews.count == imageViews.count else { return [] } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in + return imageView.image ?? blurhashOverlayImageView.image + } + } + } extension MosaicImageViewContainer { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 40eb05a5..34367d39 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -194,6 +194,8 @@ final class StatusView: UIView { let activeTextLabel = ActiveLabel(style: .default) private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + var isRevealing = true override init(frame: CGRect) { super.init(frame: frame) @@ -468,6 +470,8 @@ extension StatusView { } func updateRevealContentWarningButton(isRevealing: Bool) { + self.isRevealing = isRevealing + if !isRevealing { let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") revealContentWarningButton.setImage(image, for: .normal) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d546fea6..5d27a6a9 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,7 +13,7 @@ import CoreData import CoreDataStack import ActiveLabel -protocol StatusTableViewCellDelegate: class { +protocol StatusTableViewCellDelegate: AnyObject { var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } @@ -48,7 +48,7 @@ extension StatusTableViewCellDelegate { } final class StatusTableViewCell: UITableViewCell, StatusCell { - + static let bottomPaddingHeight: CGFloat = 10 weak var delegate: StatusTableViewCellDelegate? @@ -62,7 +62,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { let threadMetaStackView = UIStackView() let threadMetaView = ThreadMetaView() let separatorLine = UIView.separatorLine - + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! @@ -206,6 +206,19 @@ extension StatusTableViewCell { } } +// MARK: - MosaicImageViewContainerPresentable +extension StatusTableViewCell: MosaicImageViewContainerPresentable { + + var mosaicImageViewContainer: MosaicImageViewContainer { + return statusView.statusMosaicImageViewContainer + } + + var isRevealing: Bool { + return statusView.isRevealing + } + +} + // MARK: - UITableViewDelegate extension StatusTableViewCell: UITableViewDelegate { diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 8ca8a339..6c801ae4 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -151,6 +151,22 @@ extension ThreadViewController: UITableViewDelegate { } } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index a0bf523f..74d82bad 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -360,7 +360,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let progress = progressStep(for: translation) let initialSize = transitionItem.initialFrame!.size - assert(initialSize != .zero) + guard initialSize != .zero else { return } + // assert(initialSize != .zero) guard let snapshot = transitionItem.snapshotTransitioning, let finalSize = transitionItem.targetFrame?.size else { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index c1de3023..225a8320 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -46,7 +46,7 @@ extension MediaPreviewTransitionController { extension MediaPreviewTransitionController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === panGestureRecognizer { + if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer { // FIXME: should enable zoom up pan dismiss return false } diff --git a/Mastodon/Vender/CustomScheduler.swift b/Mastodon/Vender/CustomScheduler.swift new file mode 100644 index 00000000..bf87ce05 --- /dev/null +++ b/Mastodon/Vender/CustomScheduler.swift @@ -0,0 +1,50 @@ +// +// CustomScheduler.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import Foundation +import Combine + +// Ref: https://stackoverflow.com/a/59069315/3797903 +struct CustomScheduler: Scheduler { + var runLoop: RunLoop + var modes: [RunLoop.Mode] = [.default] + + func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride, + tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?, + _ action: @escaping () -> Void) -> Cancellable { + let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + return AnyCancellable { + timer.invalidate() + } + } + + func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride, + options: Never?, _ action: @escaping () -> Void) { + let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in + timer.invalidate() + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + } + + func schedule(options: Never?, _ action: @escaping () -> Void) { + runLoop.perform(inModes: modes, block: action) + } + + var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) } + var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) } + + typealias SchedulerTimeType = RunLoop.SchedulerTimeType + typealias SchedulerOptions = Never +} From f755a0eb2d690c2fc83715614c096841f213146a Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:34:45 +0800 Subject: [PATCH 10/13] fix: close button highlight --- Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 5684a6ba..a803e950 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -34,7 +34,8 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) let closeButton: UIButton = { - let button = HitTestExpandedButton() + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) button.imageView?.tintColor = .label button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) return button From 277d574254e2b4cc96777d2f2ce6df46923e3d1d Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:51:05 +0800 Subject: [PATCH 11/13] chore: remove orig files --- ...elineViewController+DebugAction.swift.orig | 353 ------------------ .../Settings/SettingsViewModel.swift.orig | 215 ----------- 2 files changed, 568 deletions(-) delete mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig delete mode 100644 Mastodon/Scene/Settings/SettingsViewModel.swift.orig diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig deleted file mode 100644 index a47aded6..00000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig +++ /dev/null @@ -1,353 +0,0 @@ -// -// 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 diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig deleted file mode 100644 index c5ae31a8..00000000 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig +++ /dev/null @@ -1,215 +0,0 @@ -// -// 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() - - let context: AppContext - - // input - let setting: CurrentValueSubject - var updateDisposeBag = Set() - var createDisposeBag = Set() - - let viewDidLoad = PassthroughSubject() - - // output - var dataSource: UITableViewDiffableDataSource! - /// 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() - - // 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) - } - -} From 597fc3fa1ab2842671c1bb8eb87f35197d75793e Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 14:41:48 +0800 Subject: [PATCH 12/13] chore: set image url fallback --- Mastodon/Extension/CoreDataStack/MastodonUser.swift | 8 ++++++++ Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index bb99b15d..7035a987 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -58,10 +58,18 @@ extension MastodonUser { return URL(string: header) } + public func headerImageURLWithFallback(domain: String) -> URL { + return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")! + } + public func avatarImageURL() -> URL? { return URL(string: avatar) } + public func avatarImageURLWithFallback(domain: String) -> URL { + return URL(string: avatar) ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + } + } extension MastodonUser { diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 1b0cc6de..f3037c08 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -59,7 +59,7 @@ final class MediaPreviewViewModel: NSObject { let managedObjectContext = self.context.managedObjectContext managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.headerImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let avatarURL = account.headerImageURLWithFallback(domain: account.domain) let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() @@ -79,7 +79,7 @@ final class MediaPreviewViewModel: NSObject { let managedObjectContext = self.context.managedObjectContext managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.avatarImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() From 51c01066d323315ff93c674036f01f6a7cc9f1c3 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 15:05:24 +0800 Subject: [PATCH 13/13] feat: add photo library permission checking --- Localization/app.json | 9 +++++++-- Mastodon/Generated/Strings.swift | 8 ++++++++ .../StatusProvider+UITableViewDelegate.swift | 14 ++++++++++++-- Mastodon/Resources/en.lproj/Localizable.strings | 3 +++ .../MediaPreviewViewController.swift | 13 +++++++++++-- Mastodon/Service/PhotoLibraryService.swift | 14 ++++++++++++++ Mastodon/Service/SettingService.swift | 16 ++++++++++++++++ 7 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index e6be2d09..4d6dcbb2 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -27,6 +27,10 @@ "title": "Sign out", "message": "Are you sure you want to sign out?", "confirm": "Sign Out" + }, + "save_photo_failure": { + "title": "Save Photo Failure", + "message": "Please enable photo libaray access permission to save photo." } }, "controls": { @@ -55,7 +59,8 @@ "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", - "report_user": "Report %s" + "report_user": "Report %s", + "settings": "Settings" }, "status": { "user_reblogged": "%s reblogged", @@ -411,4 +416,4 @@ "text_placeholder": "Type or paste additional comments" } } -} +} \ No newline at end of file diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 75e3cdd9..b4908fea 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -31,6 +31,12 @@ internal enum L10n { /// Publish Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") } + internal enum SavePhotoFailure { + /// Please enable photo libaray access permission to save photo. + internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message") + /// Save Photo Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -94,6 +100,8 @@ internal enum L10n { internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") /// See More internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") + /// Settings + internal static let settings = L10n.tr("Localizable", "Common.Controls.Actions.Settings") /// Share internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") /// Share %@ diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index ef19abab..46e4c5ab 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -171,8 +171,18 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } return self.context.photoLibraryService.saveImage(url: url) } - .sink(receiveCompletion: { _ in - // do nothing + .switchToLatest() + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + guard let error = error as? PhotoLibraryService.PhotoLibraryError, + case .noPermission = error else { return } + let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + case .finished: + break + } }, receiveValue: { _ in // do nothing }) diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 1bb3f54a..2249b15f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -5,6 +5,8 @@ "Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. Please check your internet connection."; "Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; +"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignOut.Confirm" = "Sign Out"; "Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; @@ -30,6 +32,7 @@ Please check your internet connection."; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Settings"; "Common.Controls.Actions.Share" = "Share"; "Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index a803e950..eee56e4d 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -208,8 +208,17 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { switch viewController.viewModel.item { case .status(let meta): context.photoLibraryService.saveImage(url: meta.url) - .sink { _ in - // do nothing + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + guard let error = error as? PhotoLibraryService.PhotoLibraryError, + case .noPermission = error else { return } + let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + case .finished: + break + } } receiveValue: { _ in // do nothing } diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift index 44918eb5..2dcc8f99 100644 --- a/Mastodon/Service/PhotoLibraryService.swift +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -8,12 +8,21 @@ import os.log import UIKit import Combine +import Photos import AlamofireImage final class PhotoLibraryService: NSObject { } +extension PhotoLibraryService { + + enum PhotoLibraryError: Error { + case noPermission + } + +} + extension PhotoLibraryService { func saveImage(url: URL) -> AnyPublisher { @@ -21,6 +30,11 @@ extension PhotoLibraryService { let notificationFeedbackGenerator = UINotificationFeedbackGenerator() return Future { promise in + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .denied else { + promise(.failure(PhotoLibraryError.noPermission)) + return + } + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in guard let self = self else { return } switch response.result { diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 8683b397..f0375bad 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -171,3 +171,19 @@ final class SettingService { } } + +extension SettingService { + + static func openSettingsAlertController(title: String, message: String) -> UIAlertController { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let settingAction = UIAlertAction(title: L10n.Common.Controls.Actions.settings, style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + alertController.addAction(settingAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + return alertController + } + +}