forked from zelo72/mastodon-ios
Merge branch 'develop' into feature/statusMenu
This commit is contained in:
commit
5f921c1537
|
@ -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",
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
|
||||
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
|
||||
5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = "<group>"; };
|
||||
5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -810,6 +828,19 @@
|
|||
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
|
||||
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
|
||||
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = "<group>"; };
|
||||
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
|
||||
DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionController.swift; sourceTree = "<group>"; };
|
||||
DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
|
||||
DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionItem.swift; sourceTree = "<group>"; };
|
||||
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitioningMath.swift; sourceTree = "<group>"; };
|
||||
DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewModel.swift; sourceTree = "<group>"; };
|
||||
DB6180F326391D110018D199 /* MediaPreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageView.swift; sourceTree = "<group>"; };
|
||||
DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = "<group>"; };
|
||||
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = "<group>"; };
|
||||
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = "<group>"; };
|
||||
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = "<group>"; };
|
||||
|
@ -850,6 +881,7 @@
|
|||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = "<group>"; };
|
||||
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = "<group>"; };
|
||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -917,6 +949,10 @@
|
|||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
|
||||
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
||||
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = "<group>"; };
|
||||
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; };
|
||||
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
|
||||
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
|
||||
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1251,6 +1287,8 @@
|
|||
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
|
||||
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
|
||||
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
|
||||
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
|
||||
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */,
|
||||
);
|
||||
path = Vender;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1271,6 +1309,7 @@
|
|||
2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */,
|
||||
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
|
||||
DB6D9F6226357848008423CD /* SettingService.swift */,
|
||||
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1351,6 +1390,7 @@
|
|||
DB68A04F25E9028800CFDF14 /* NavigationController */,
|
||||
DB9D6C2025E502C60051B173 /* ViewModel */,
|
||||
2D7631A525C1532D00929FB9 /* View */,
|
||||
DBA5E7A6263BD298004598BB /* ContextMenu */,
|
||||
);
|
||||
path = Share;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1758,6 +1798,56 @@
|
|||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180DE263919350018D199 /* MediaPreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180E1263919780018D199 /* Paging */,
|
||||
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */,
|
||||
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */,
|
||||
);
|
||||
path = MediaPreview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180E1263919780018D199 /* Paging */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180F026391CAB0018D199 /* Image */,
|
||||
DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */,
|
||||
);
|
||||
path = Paging;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180E426391A500018D199 /* Transition */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
|
||||
DB6180E726391B580018D199 /* MediaPreview */,
|
||||
);
|
||||
path = Transition;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180E726391B580018D199 /* MediaPreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */,
|
||||
DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */,
|
||||
DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */,
|
||||
DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */,
|
||||
DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */,
|
||||
);
|
||||
path = MediaPreview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6180F026391CAB0018D199 /* Image */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */,
|
||||
DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */,
|
||||
DB6180F326391D110018D199 /* MediaPreviewImageView.swift */,
|
||||
);
|
||||
path = Image;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6804802637CD4C00430867 /* AppShared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -2131,6 +2223,24 @@
|
|||
path = Helper;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBA5E7A6263BD298004598BB /* ContextMenu */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBA5E7A7263BD29F004598BB /* ImagePreview */,
|
||||
);
|
||||
path = ContextMenu;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBA5E7A7263BD29F004598BB /* ImagePreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */,
|
||||
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */,
|
||||
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */,
|
||||
);
|
||||
path = ImagePreview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>17</integer>
|
||||
<integer>14</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -32,7 +32,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>18</integer>
|
||||
<integer>15</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
|
||||
"revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
|
||||
"version": "6.2.1"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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<Status?, Never>,
|
||||
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<UIImage, Error>? 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<Status?, Never>, index: Int) -> AnyPublisher<Attachment?, Never> {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 %@";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -15,7 +15,7 @@ import GameplayKit
|
|||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var emptyView: UIStackView = {
|
||||
let emptyView = UIStackView()
|
||||
emptyView.axis = .vertical
|
||||
|
@ -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
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Int, Never>
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<UIImage?, Never>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// MediaPreviewPagingViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pageboy
|
||||
|
||||
final class MediaPreviewPagingViewController: PageboyViewController { }
|
|
@ -14,7 +14,7 @@ import AVKit
|
|||
import Combine
|
||||
import GameplayKit
|
||||
|
||||
final class FavoriteViewController: UIViewController, NeedsDependency {
|
||||
final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -22,6 +22,8 @@ final class FavoriteViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: FavoriteViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -24,6 +24,7 @@ final class ProfileHeaderViewModel {
|
|||
// output
|
||||
let displayProfileInfo = ProfileInfo()
|
||||
let editProfileInfo = ProfileInfo()
|
||||
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
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) {
|
||||
|
|
|
@ -13,7 +13,7 @@ import CoreDataStack
|
|||
import GameplayKit
|
||||
|
||||
// TODO: adopt MediaPreviewableViewController
|
||||
final class UserTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -21,7 +21,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: UserTimelineViewModel!
|
||||
|
||||
// let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
|
@ -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
|
||||
|
|
|
@ -13,13 +13,15 @@ import GameplayKit
|
|||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class PublicTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class PublicTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: PublicTimelineViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let aspectRatio: CGSize
|
||||
let thumbnail: UIImage?
|
||||
let url = CurrentValueSubject<URL?, Never>(nil)
|
||||
|
||||
init(aspectRatio: CGSize, thumbnail: UIImage?) {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.thumbnail = thumbnail
|
||||
}
|
||||
|
||||
}
|
|
@ -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?
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -195,6 +195,8 @@ final class StatusView: UIView {
|
|||
|
||||
private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
|
||||
var isRevealing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
|
@ -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)
|
||||
|
|
|
@ -13,7 +13,7 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import ActiveLabel
|
||||
|
||||
protocol StatusTableViewCellDelegate: class {
|
||||
protocol StatusTableViewCellDelegate: AnyObject {
|
||||
var context: AppContext! { get }
|
||||
var managedObjectContext: NSManagedObjectContext { get }
|
||||
|
||||
|
@ -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 {
|
||||
|
||||
|
|
|
@ -48,9 +48,17 @@ struct MosaicMeta {
|
|||
|
||||
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
|
||||
return Future { promise in
|
||||
workingQueue.async {
|
||||
let image = self.blurhashImage()
|
||||
promise(.success(image))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func blurhashImage() -> UIImage? {
|
||||
guard let blurhash = blurhash else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
let imageSize: CGSize = {
|
||||
|
@ -66,11 +74,9 @@ struct MosaicMeta {
|
|||
}
|
||||
}()
|
||||
|
||||
workingQueue.async {
|
||||
let image = UIImage(blurHash: blurhash, size: imageSize)
|
||||
promise(.success(image))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import Combine
|
|||
import CoreData
|
||||
import AVKit
|
||||
|
||||
final class ThreadViewController: UIViewController, NeedsDependency {
|
||||
final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -19,6 +19,8 @@ final class ThreadViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ThreadViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||
|
||||
let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem(
|
||||
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// MediaPreviewingViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol MediaPreviewingViewController: AnyObject {
|
||||
func isInteractiveDismissable() -> Bool
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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<UIImage, Error> {
|
||||
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||
|
||||
return Future<UIImage, Error> { 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -30,7 +30,9 @@ class AppContext: ObservableObject {
|
|||
let statusPublishService = StatusPublishService()
|
||||
let notificationService: NotificationService
|
||||
let settingService: SettingService
|
||||
|
||||
let blockDomainService: BlockDomainService
|
||||
let photoLibraryService = PhotoLibraryService()
|
||||
|
||||
let documentStore: DocumentStore
|
||||
private var documentStoreSubscription: AnyCancellable!
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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<T : Comparable>(_ x0: T, _ x1: T, _ v: T) -> T {
|
||||
return max(x0, min(x1, v))
|
||||
}
|
||||
|
||||
func lerp<T : FloatingPoint>(_ v0: T, _ v1: T, _ t: T) -> T {
|
||||
return v0 + (v1 - v0) * t
|
||||
}
|
||||
|
||||
|
||||
func -(lhs: CGPoint, rhs: CGPoint) -> CGVector {
|
||||
return CGVector(dx: lhs.x - rhs.x, dy: lhs.y - rhs.y)
|
||||
}
|
||||
|
||||
func -(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy)
|
||||
}
|
||||
|
||||
func -(lhs: CGVector, rhs: CGVector) -> CGVector {
|
||||
return CGVector(dx: lhs.dx - rhs.dx, dy: lhs.dy - rhs.dy)
|
||||
}
|
||||
|
||||
func +(lhs: CGPoint, rhs: CGPoint) -> CGVector {
|
||||
return CGVector(dx: lhs.x + rhs.x, dy: lhs.y + rhs.y)
|
||||
}
|
||||
|
||||
func +(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy)
|
||||
}
|
||||
|
||||
func +(lhs: CGVector, rhs: CGVector) -> CGVector {
|
||||
return CGVector(dx: lhs.dx + rhs.dx, dy: lhs.dy + rhs.dy)
|
||||
}
|
||||
|
||||
func *(left: CGVector, right:CGFloat) -> CGVector {
|
||||
return CGVector(dx: left.dx * right, dy: left.dy * right)
|
||||
}
|
||||
|
||||
extension CGPoint {
|
||||
var vector: CGVector {
|
||||
return CGVector(dx: x, dy: y)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGVector {
|
||||
var magnitude: CGFloat {
|
||||
return sqrt(dx*dx + dy*dy)
|
||||
}
|
||||
|
||||
var point: CGPoint {
|
||||
return CGPoint(x: dx, y: dy)
|
||||
}
|
||||
|
||||
func apply(transform t: CGAffineTransform) -> CGVector {
|
||||
return point.applying(t).vector
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue