Merge branch 'develop' into feature/statusMenu

This commit is contained in:
sunxiaojian 2021-05-06 16:31:34 +08:00
commit 5f921c1537
48 changed files with 2695 additions and 63 deletions

View File

@ -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",

View File

@ -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 */,

View File

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

View File

@ -69,7 +69,7 @@
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
"revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
"version": "6.2.1"
}
},

View File

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

View File

@ -12,7 +12,7 @@ import os.log
import UIKit
import AVKit
protocol StatusCell : DisposeBagCollectable {
protocol StatusCell: DisposeBagCollectable {
var statusView: StatusView { get }
var pollCountdownSubscription: AnyCancellable? { get set }
}
@ -145,7 +145,7 @@ extension StatusSection {
status: Status,
requestUserID: String,
statusItemAttribute: Item.StatusAttribute
) {
) {
// set header
StatusSection.configureHeader(cell: cell, status: status)
ManagedObjectObserver.observe(object: status)
@ -432,13 +432,12 @@ extension StatusSection {
.sink { _ in
// do nothing
} receiveValue: { [weak dependency, weak cell] change in
guard let cell = cell else { return }
guard let dependency = dependency else { return }
guard case .update(let object) = change.changeType,
let status = object as? Status else { return }
guard let cell = cell as? StatusTableViewCell else { return }
guard let statusTableViewCell = cell as? StatusTableViewCell else { return }
StatusSection.configureActionToolBar(
cell: cell,
cell: statusTableViewCell,
indexPath: indexPath,
dependency: dependency,
status: status,

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

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

View File

@ -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
}
}

View File

@ -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

View File

@ -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)

View File

@ -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 %@";

View File

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

View File

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

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

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

View File

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

View File

@ -0,0 +1,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)
}
}

View File

@ -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
}
}

View File

@ -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?
}

View File

@ -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) {

View File

@ -194,6 +194,8 @@ final class StatusView: UIView {
let activeTextLabel = ActiveLabel(style: .default)
private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
var isRevealing = true
override init(frame: CGRect) {
super.init(frame: frame)
@ -468,6 +470,8 @@ extension StatusView {
}
func updateRevealContentWarningButton(isRevealing: Bool) {
self.isRevealing = isRevealing
if !isRevealing {
let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill")
revealContentWarningButton.setImage(image, for: .normal)

View File

@ -13,7 +13,7 @@ import CoreData
import CoreDataStack
import ActiveLabel
protocol StatusTableViewCellDelegate: class {
protocol StatusTableViewCellDelegate: AnyObject {
var context: AppContext! { get }
var managedObjectContext: NSManagedObjectContext { get }
@ -48,7 +48,7 @@ extension StatusTableViewCellDelegate {
}
final class StatusTableViewCell: UITableViewCell, StatusCell {
static let bottomPaddingHeight: CGFloat = 10
weak var delegate: StatusTableViewCellDelegate?
@ -62,7 +62,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
let threadMetaStackView = UIStackView()
let threadMetaView = ThreadMetaView()
let separatorLine = UIView.separatorLine
var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint!
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
@ -206,6 +206,19 @@ extension StatusTableViewCell {
}
}
// MARK: - MosaicImageViewContainerPresentable
extension StatusTableViewCell: MosaicImageViewContainerPresentable {
var mosaicImageViewContainer: MosaicImageViewContainer {
return statusView.statusMosaicImageViewContainer
}
var isRevealing: Bool {
return statusView.isRevealing
}
}
// MARK: - UITableViewDelegate
extension StatusTableViewCell: UITableViewDelegate {

View File

@ -48,29 +48,35 @@ struct MosaicMeta {
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
return Future { promise in
guard let blurhash = blurhash else {
promise(.success(nil))
return
}
let imageSize: CGSize = {
let aspectRadio = size.width / size.height
if size.width > size.height {
let width: CGFloat = MosaicMeta.edgeMaxLength
let height = width / aspectRadio
return CGSize(width: width, height: height)
} else {
let height: CGFloat = MosaicMeta.edgeMaxLength
let width = height * aspectRadio
return CGSize(width: width, height: height)
}
}()
workingQueue.async {
let image = UIImage(blurHash: blurhash, size: imageSize)
let image = self.blurhashImage()
promise(.success(image))
}
}
.eraseToAnyPublisher()
}
func blurhashImage() -> UIImage? {
guard let blurhash = blurhash else {
return nil
}
let imageSize: CGSize = {
let aspectRadio = size.width / size.height
if size.width > size.height {
let width: CGFloat = MosaicMeta.edgeMaxLength
let height = width / aspectRadio
return CGSize(width: width, height: height)
} else {
let height: CGFloat = MosaicMeta.edgeMaxLength
let width = height * aspectRadio
return CGSize(width: width, height: height)
}
}()
let image = UIImage(blurHash: blurhash, size: imageSize)
return image
}
}

View File

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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,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
}
}

View File

@ -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
}
}

View File

@ -30,8 +30,10 @@ class AppContext: ObservableObject {
let statusPublishService = StatusPublishService()
let notificationService: NotificationService
let settingService: SettingService
let blockDomainService: BlockDomainService
let blockDomainService: BlockDomainService
let photoLibraryService = PhotoLibraryService()
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!

View File

@ -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
}

View File

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