diff --git a/Localization/app.json b/Localization/app.json index 85c1b10e..28b95fa2 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -31,6 +31,10 @@ "block_domain": { "message": "Are you really, really sure you want to block the entire %s ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", "block_entire_domain": "Block entire domain" + }, + "save_photo_failure": { + "title": "Save Photo Failure", + "message": "Please enable photo libaray access permission to save photo." } }, "controls": { @@ -62,7 +66,8 @@ "skip": "Skip", "report_user": "Report %s", "block_domain": "Block %s", - "unblock_domain": "Unblock %s" + "unblock_domain": "Unblock %s", + "settings": "Settings" }, "status": { "user_reblogged": "%s reblogged", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b3f737a4..042901c9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -258,6 +258,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 */; }; @@ -306,6 +319,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 */; }; @@ -371,6 +385,10 @@ 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 */; }; + 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 */; }; @@ -686,9 +704,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 = ""; }; @@ -810,6 +828,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 = ""; }; @@ -850,6 +881,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 = ""; }; @@ -917,6 +949,10 @@ 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 = ""; }; + 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 = ""; }; @@ -1251,6 +1287,8 @@ DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, ); path = Vender; sourceTree = ""; @@ -1271,6 +1309,7 @@ 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, DB6D9F6226357848008423CD /* SettingService.swift */, + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, ); path = Service; sourceTree = ""; @@ -1351,6 +1390,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, + DBA5E7A6263BD298004598BB /* ContextMenu */, ); path = Share; sourceTree = ""; @@ -1758,6 +1798,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 = ( @@ -1956,6 +2046,7 @@ children = ( 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, + DB6180E426391A500018D199 /* Transition */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, @@ -1969,6 +2060,7 @@ DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, DB938EEB2623F52600E5B6C1 /* Thread */, + DB6180DE263919350018D199 /* MediaPreview */, ); path = Scene; sourceTree = ""; @@ -2131,6 +2223,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 = ( @@ -2745,6 +2855,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 */, @@ -2774,8 +2885,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 */, @@ -2794,6 +2907,8 @@ DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 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 */, @@ -2810,6 +2925,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 */, @@ -2833,8 +2949,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 */, @@ -2842,6 +2960,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 */, @@ -2867,12 +2986,14 @@ 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 */, 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 */, @@ -2880,6 +3001,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 */, @@ -2950,6 +3072,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 */, @@ -2959,12 +3082,14 @@ 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 */, 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 */, @@ -3028,6 +3153,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+Provider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, @@ -3065,10 +3191,12 @@ 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.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.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index b9f39148..47136a2c 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,7 +69,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", "version": "6.2.1" } }, 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/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f31c2da8..b5aadbf1 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 } } @@ -145,7 +145,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) @@ -432,13 +432,12 @@ extension StatusSection { .sink { _ in // do nothing } receiveValue: { [weak dependency, weak cell] change in - guard let cell = cell else { return } guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } - guard let cell = cell as? StatusTableViewCell else { return } + guard let statusTableViewCell = cell as? StatusTableViewCell else { return } StatusSection.configureActionToolBar( - cell: cell, + cell: statusTableViewCell, indexPath: indexPath, dependency: dependency, status: status, diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 96f95e5e..b780f591 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -67,10 +67,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/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b06a0d68..b12d78df 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -39,6 +39,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") @@ -106,6 +112,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 post diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 1c2c78da..3d2dba80 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 = false // default clip corner on image ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage self.borderColor = borderColor self.borderWidth = borderWidth + self.keepImageCorner = keepImageCorner } } 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/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index cd6cbf58..46e4c5ab 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -106,4 +106,262 @@ 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) + } + .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 + }) + .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 03f84216..56e9d474 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -528,6 +528,64 @@ 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 source = status else { return } + + let status = source.reblog ?? source + + let meta = MediaPreviewViewModel.StatusImagePreviewMeta( + statusObjectID: status.objectID, + initialIndex: index, + preloadThumbnailImages: mosaicImageView.thumbnails() + ) + 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)) + } + } + .store(in: &provider.disposeBag) + } +} + extension StatusProviderFacade { enum Target { case primary // original status 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/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e3ed6cf0..e18674a7 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -7,6 +7,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?"; @@ -33,6 +35,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.SharePost" = "Share post"; "Common.Controls.Actions.ShareUser" = "Share %@"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ea1a03aa..638aa766 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 @@ -222,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 3329ce8d..6db1c26f 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 @@ -376,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 new file mode 100644 index 00000000..eee56e4d --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -0,0 +1,242 @@ +// +// 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 { + + static let closeButtonSize = CGSize(width: 30, height: 30) + + 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() + + 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 = 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 + }() + + 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) + + 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) + } + case .profileAvatar, .profileBanner: + break + } + } + .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) + } + +} + +// MARK: - MediaPreviewingViewController +extension MediaPreviewViewController: MediaPreviewingViewController { + + func isInteractiveDismissable() -> Bool { + 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 + 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 + } + +} + +// 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 + viewModel.currentPage.value = 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) { + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { + // 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 { [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 + } + .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/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift new file mode 100644 index 00000000..f3037c08 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -0,0 +1,148 @@ +// +// 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? + let currentPage: CurrentValueSubject + + // output + let pushTransitionItem: MediaPreviewTransitionItem + let viewControllers: [UIViewController] + + init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + 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.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + default: + continue + } + } + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(meta.initialIndex) + self.pushTransitionItem = pushTransitionItem + 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.headerImageURLWithFallback(domain: account.domain) + 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) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) + 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 profileBanner(ProfileBannerImagePreviewMeta) + case local(LocalImagePreviewMeta) + } + + struct StatusImagePreviewMeta { + let statusObjectID: NSManagedObjectID + let initialIndex: Int + let preloadThumbnailImages: [UIImage?] + } + + struct ProfileAvatarImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + + struct ProfileBannerImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: 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..0f2ba82f --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -0,0 +1,217 @@ +// +// 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() + + // set bounce when zoom in + alwaysBounceVertical = zoomScale > minimumZoomScale + } + + 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..7ac3c202 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -0,0 +1,146 @@ +// +// MediaPreviewImageViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine + +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 { + + var disposeBag = Set() + var observations = 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) + + let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) + previewImageView.addInteraction(previewImageViewContextMenuInteraction) + + 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) + } + +} + +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) + } + +} + +// 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 new file mode 100644 index 00000000..6be61dfc --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -0,0 +1,73 @@ +// +// MediaPreviewImageViewModel.swift +// Mastodon +// +// 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) + } + +} + +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 { + 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..01d76f4b 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 = { @@ -118,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/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 38695f34..3949c328 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 ) ) } @@ -329,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 @@ -348,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/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 09d99c51..1e09116d 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,7 +10,9 @@ import UIKit import ActiveLabel import TwitterTextEditor -protocol ProfileHeaderViewDelegate: class { +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) @@ -23,6 +25,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 @@ -40,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 @@ -51,6 +56,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 +203,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: 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 avatarImageView.addSubview(editAvatarBackgroundView) NSLayoutConstraint.activate([ @@ -313,6 +337,14 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self + 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) @@ -372,6 +404,16 @@ 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) + } + + @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 b4d52d75..5d819c6b 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 @@ -170,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 @@ -195,6 +198,10 @@ extension ProfileViewController { return } + guard !isTitleViewDisplaying else { + return + } + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) @@ -665,6 +672,72 @@ 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, 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 } + + 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/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 2ec350b0..503ce04c 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() @@ -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/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/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 54e25ed8..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,6 +296,41 @@ extension MosaicImageViewContainer { } +// FIXME: refactor blurhash image and preview 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 + } + } + + 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 { @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { 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 3ab03f3e..4a897a34 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/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/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index bd15b930..6c801ae4 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( @@ -149,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 new file mode 100644 index 00000000..74d82bad --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -0,0 +1,391 @@ +// +// MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import func AVFoundation.AVMakeRect + +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) + // set to image hidden + toVC.pagingViewConttroller.view.alpha = 0 + // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` + transitionItem.source.updateAppearance(position: .start, 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) + } + + 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), + 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 + + 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 { + if let targetFrame = targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } 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() + self.transitionItem.source.updateAppearance(position: position, index: nil) + transitionContext.completeTransition(position == .end) + } + + return animator + } + +} + +// MARK: - UIViewControllerInteractiveTransitioning +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + super.startInteractiveTransition(transitionContext) + + switch operation { + case .pop: + // Note: change item.imageView transform via pan gesture + 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), + 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 ?? snapshot.frame + + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + + let animator = popInteractiveTransitionAnimator + + let blurEffect = fromVC.visualEffectView.effect + 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 } + } + + 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 { + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) + } + 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") + isTransitionContextFinish = true + animate(targetPosition) + + targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() + 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 { + 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 + } else { + self.transitionItem.snapshotTransitioning?.alpha = 1 + } + } + } + + // 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 + guard initialSize != .zero else { return } + // 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..225a8320 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -0,0 +1,125 @@ +// +// 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 || otherGestureRecognizer === 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) + + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .push, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + 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 + } + + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .pop, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + 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..47fdd215 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -0,0 +1,64 @@ +// +// MediaPreviewTransitionItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import CoreData + +class MediaPreviewTransitionItem: Identifiable { + + let id: UUID + let source: Source + var previewableViewController: MediaPreviewableViewController + + // source + // value maybe invalid when preview paging + var image: UIImage? + var aspectRatio: CGSize? + var initialFrame: CGRect? = nil + var sourceImageView: UIImageView? + var sourceImageViewCornerRadius: CGFloat? + + // target + var targetFrame: CGRect? = nil + + // transitioning + var imageView: UIImageView? + var snapshotRaw: UIView? + var snapshotTransitioning: UIView? + var touchOffset: CGVector = CGVector.zero + + init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { + self.id = id + self.source = source + self.previewableViewController = previewableViewController + } + +} + +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 + 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 + case .profileBanner: + break // keep source + } + } + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift new file mode 100644 index 00000000..8029c09d --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -0,0 +1,28 @@ +// +// MediaPreviewableViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit + +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) + case .profileAvatar(let profileHeaderView): + return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) + case .profileBanner: + return nil // fallback to snapshot.frame + } + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift new file mode 100644 index 00000000..9c6e56d0 --- /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: AnyObject { + 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/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift new file mode 100644 index 00000000..2dcc8f99 --- /dev/null +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -0,0 +1,83 @@ +// +// PhotoLibraryService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-29. +// + +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 { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + 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 { + 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/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 + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 962bc792..55d5841f 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -30,8 +30,10 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService - let blockDomainService: BlockDomainService - + + let blockDomainService: BlockDomainService + let photoLibraryService = PhotoLibraryService() + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! 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 +} 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 + } +}