forked from zelo72/mastodon-ios
Merge pull request #127 from tootsuite/feature/media-preview
Add image preview for timeline and profile scene
This commit is contained in:
commit
c6584f4cab
|
@ -27,6 +27,10 @@
|
|||
"title": "Sign out",
|
||||
"message": "Are you sure you want to sign out?",
|
||||
"confirm": "Sign Out"
|
||||
},
|
||||
"save_photo_failure": {
|
||||
"title": "Save Photo Failure",
|
||||
"message": "Please enable photo libaray access permission to save photo."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -55,7 +59,8 @@
|
|||
"find_people": "Find people to follow",
|
||||
"manually_search": "Manually search instead",
|
||||
"skip": "Skip",
|
||||
"report_user": "Report %s"
|
||||
"report_user": "Report %s",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"status": {
|
||||
"user_reblogged": "%s reblogged",
|
||||
|
@ -411,4 +416,4 @@
|
|||
"text_placeholder": "Type or paste additional comments"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -255,6 +255,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 */; };
|
||||
|
@ -303,6 +316,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 */; };
|
||||
|
@ -368,6 +382,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 */; };
|
||||
|
@ -680,9 +698,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>"; };
|
||||
|
@ -804,6 +822,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>"; };
|
||||
|
@ -844,6 +875,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>"; };
|
||||
|
@ -911,6 +943,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>"; };
|
||||
|
@ -1245,6 +1281,8 @@
|
|||
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
|
||||
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
|
||||
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
|
||||
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
|
||||
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */,
|
||||
);
|
||||
path = Vender;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1264,6 +1302,7 @@
|
|||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
||||
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
|
||||
DB6D9F6226357848008423CD /* SettingService.swift */,
|
||||
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1344,6 +1383,7 @@
|
|||
DB68A04F25E9028800CFDF14 /* NavigationController */,
|
||||
DB9D6C2025E502C60051B173 /* ViewModel */,
|
||||
2D7631A525C1532D00929FB9 /* View */,
|
||||
DBA5E7A6263BD298004598BB /* ContextMenu */,
|
||||
);
|
||||
path = Share;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1750,6 +1790,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 = (
|
||||
|
@ -1947,6 +2037,7 @@
|
|||
children = (
|
||||
5D03938E2612D200007FE196 /* Webview */,
|
||||
2D7631A425C1532200929FB9 /* Share */,
|
||||
DB6180E426391A500018D199 /* Transition */,
|
||||
DB8AF54E25C13703002E6C99 /* MainTab */,
|
||||
DB01409B25C40BB600F9F3CF /* Onboarding */,
|
||||
2D38F1D325CD463600561493 /* HomeTimeline */,
|
||||
|
@ -1960,6 +2051,7 @@
|
|||
DB9D6C0825E4F5A60051B173 /* Profile */,
|
||||
DB789A1025F9F29B0071ACA0 /* Compose */,
|
||||
DB938EEB2623F52600E5B6C1 /* Thread */,
|
||||
DB6180DE263919350018D199 /* MediaPreview */,
|
||||
);
|
||||
path = Scene;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2122,6 +2214,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 = (
|
||||
|
@ -2736,6 +2846,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 */,
|
||||
|
@ -2764,8 +2875,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 */,
|
||||
|
@ -2784,6 +2897,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 */,
|
||||
|
@ -2800,6 +2915,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 */,
|
||||
|
@ -2823,8 +2939,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 */,
|
||||
|
@ -2832,6 +2950,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 */,
|
||||
|
@ -2857,12 +2976,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 */,
|
||||
|
@ -2870,6 +2991,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 */,
|
||||
|
@ -2939,6 +3061,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 */,
|
||||
|
@ -2948,12 +3071,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 */,
|
||||
|
@ -3017,6 +3142,7 @@
|
|||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
|
||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
|
||||
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
|
||||
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
||||
|
@ -3054,10 +3180,12 @@
|
|||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */,
|
||||
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
|
||||
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
|
||||
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
|
||||
DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */,
|
||||
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
||||
DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */,
|
||||
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
|
||||
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
|
||||
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -71,6 +71,9 @@ extension SceneCoordinator {
|
|||
// suggestion account
|
||||
case suggestionAccount(viewModel: SuggestionAccountViewModel)
|
||||
|
||||
// media preview
|
||||
case mediaPreview(viewModel: MediaPreviewViewModel)
|
||||
|
||||
// misc
|
||||
case safari(url: URL)
|
||||
case alertController(alertController: UIAlertController)
|
||||
|
@ -266,6 +269,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = SuggestionAccountViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mediaPreview(let viewModel):
|
||||
let _viewController = MediaPreviewViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .safari(let url):
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
|
|
|
@ -12,7 +12,7 @@ import os.log
|
|||
import UIKit
|
||||
import AVKit
|
||||
|
||||
protocol StatusCell : DisposeBagCollectable {
|
||||
protocol StatusCell: DisposeBagCollectable {
|
||||
var statusView: StatusView { get }
|
||||
var pollCountdownSubscription: AnyCancellable? { get set }
|
||||
}
|
||||
|
@ -142,7 +142,7 @@ extension StatusSection {
|
|||
status: Status,
|
||||
requestUserID: String,
|
||||
statusItemAttribute: Item.StatusAttribute
|
||||
) {
|
||||
) {
|
||||
// set header
|
||||
StatusSection.configureHeader(cell: cell, status: status)
|
||||
ManagedObjectObserver.observe(object: status)
|
||||
|
|
|
@ -58,10 +58,18 @@ extension MastodonUser {
|
|||
return URL(string: header)
|
||||
}
|
||||
|
||||
public func headerImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
|
||||
}
|
||||
|
||||
public func avatarImageURL() -> URL? {
|
||||
return URL(string: avatar)
|
||||
}
|
||||
|
||||
public func avatarImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: avatar) ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
|
|
@ -31,6 +31,12 @@ internal enum L10n {
|
|||
/// Publish Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
|
||||
}
|
||||
internal enum SavePhotoFailure {
|
||||
/// Please enable photo libaray access permission to save photo.
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message")
|
||||
/// Save Photo Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title")
|
||||
}
|
||||
internal enum ServerError {
|
||||
/// Server Error
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
|
||||
|
@ -94,6 +100,8 @@ internal enum L10n {
|
|||
internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto")
|
||||
/// See More
|
||||
internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore")
|
||||
/// Settings
|
||||
internal static let settings = L10n.tr("Localizable", "Common.Controls.Actions.Settings")
|
||||
/// Share
|
||||
internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share")
|
||||
/// Share %@
|
||||
|
|
|
@ -51,7 +51,10 @@ extension AvatarConfigurableView {
|
|||
avatarConfigurableView(self, didFinishConfiguration: configuration)
|
||||
}
|
||||
|
||||
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
|
||||
let filter = ScaledToSizeWithRoundedCornersFilter(
|
||||
size: Self.configurableAvatarImageSize,
|
||||
radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius
|
||||
)
|
||||
|
||||
// set placeholder if no asset
|
||||
guard let avatarImageURL = configuration.avatarImageURL else {
|
||||
|
@ -91,6 +94,12 @@ extension AvatarConfigurableView {
|
|||
runImageTransitionIfCached: false,
|
||||
completion: nil
|
||||
)
|
||||
|
||||
if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner {
|
||||
configurableAvatarImageView?.layer.masksToBounds = true
|
||||
configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
|
||||
configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
|
||||
}
|
||||
}
|
||||
|
||||
configureLayerBorder(view: avatarImageView, configuration: configuration)
|
||||
|
@ -148,16 +157,20 @@ struct AvatarConfigurableViewConfiguration {
|
|||
let borderColor: UIColor?
|
||||
let borderWidth: CGFloat?
|
||||
|
||||
let keepImageCorner: Bool
|
||||
|
||||
init(
|
||||
avatarImageURL: URL?,
|
||||
placeholderImage: UIImage? = nil,
|
||||
borderColor: UIColor? = nil,
|
||||
borderWidth: CGFloat? = nil
|
||||
borderWidth: CGFloat? = nil,
|
||||
keepImageCorner: Bool = false // default clip corner on image
|
||||
) {
|
||||
self.avatarImageURL = avatarImageURL
|
||||
self.placeholderImage = placeholderImage
|
||||
self.borderColor = borderColor
|
||||
self.borderWidth = borderWidth
|
||||
self.keepImageCorner = keepImageCorner
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -58,9 +58,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
// MARK: - MosciaImageViewContainerDelegate
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
|
||||
|
@ -76,6 +74,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
|
||||
}
|
||||
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider & MediaPreviewableViewController {
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
|
||||
StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PollTableView
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
|
||||
|
|
|
@ -106,4 +106,262 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
|
||||
}
|
||||
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {}
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
|
||||
private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable
|
||||
|
||||
func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil }
|
||||
guard imagePreviewPresentableCell.isRevealing else { return nil }
|
||||
|
||||
let status = status(for: nil, indexPath: indexPath)
|
||||
|
||||
return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
private func contextMenuConfiguration(
|
||||
_ tableView: UITableView,
|
||||
status: Future<Status?, Never>,
|
||||
imagePreviewPresentableCell presentable: ImagePreviewPresentableCell,
|
||||
contextMenuConfigurationForRowAt indexPath: IndexPath,
|
||||
point: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
let imageViews = presentable.mosaicImageViewContainer.imageViews
|
||||
guard !imageViews.isEmpty else { return nil }
|
||||
|
||||
for (i, imageView) in imageViews.enumerated() {
|
||||
let pointInImageView = imageView.convert(point, from: tableView)
|
||||
guard imageView.point(inside: pointInImageView, with: nil) else {
|
||||
continue
|
||||
}
|
||||
guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else {
|
||||
// not provide preview until image ready
|
||||
return nil
|
||||
|
||||
}
|
||||
// setup preview
|
||||
let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image)
|
||||
status
|
||||
.sink { status in
|
||||
guard let status = (status?.reblog ?? status),
|
||||
let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }),
|
||||
i < media.count, let url = URL(string: media[i].url) else {
|
||||
return
|
||||
}
|
||||
|
||||
contextMenuImagePreviewViewModel.url.value = url
|
||||
}
|
||||
.store(in: &contextMenuImagePreviewViewModel.disposeBag)
|
||||
|
||||
// setup context menu
|
||||
let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in
|
||||
// know issue: preview size looks not as large as system default preview
|
||||
let previewProvider = ContextMenuImagePreviewViewController()
|
||||
previewProvider.viewModel = contextMenuImagePreviewViewModel
|
||||
return previewProvider
|
||||
} actionProvider: { _ -> UIMenu? in
|
||||
let savePhotoAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.attachment(of: status, index: i)
|
||||
.setFailureType(to: Error.self)
|
||||
.compactMap { attachment -> AnyPublisher<UIImage, Error>? in
|
||||
guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil }
|
||||
return self.context.photoLibraryService.saveImage(url: url)
|
||||
}
|
||||
.switchToLatest()
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
guard let error = error as? PhotoLibraryService.PhotoLibraryError,
|
||||
case .noPermission = error else { return }
|
||||
let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message)
|
||||
self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
}, receiveValue: { _ in
|
||||
// do nothing
|
||||
})
|
||||
.store(in: &self.context.disposeBag)
|
||||
}
|
||||
let shareAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.attachment(of: status, index: i)
|
||||
.sink(receiveValue: { [weak self] attachment in
|
||||
guard let self = self else { return }
|
||||
guard let attachment = attachment, let url = URL(string: attachment.url) else { return }
|
||||
let applicationActivities: [UIActivity] = [
|
||||
SafariActivity(sceneCoordinator: self.coordinator)
|
||||
]
|
||||
let activityViewController = UIActivityViewController(
|
||||
activityItems: [url],
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
activityViewController.popoverPresentationController?.sourceView = imageView
|
||||
self.present(activityViewController, animated: true, completion: nil)
|
||||
})
|
||||
.store(in: &self.context.disposeBag)
|
||||
}
|
||||
let children = [savePhotoAction, shareAction]
|
||||
return UIMenu(title: "", image: nil, children: children)
|
||||
}
|
||||
contextMenuConfiguration.indexPath = indexPath
|
||||
contextMenuConfiguration.index = i
|
||||
return contextMenuConfiguration
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func attachment(of status: Future<Status?, Never>, index: Int) -> AnyPublisher<Attachment?, Never> {
|
||||
status
|
||||
.map { status in
|
||||
guard let status = status?.reblog ?? status else { return nil }
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
||||
guard index < media.count else { return nil }
|
||||
return media[index]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return _handleTableView(tableView, configuration: configuration)
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return _handleTableView(tableView, configuration: configuration)
|
||||
}
|
||||
|
||||
private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil }
|
||||
guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else {
|
||||
return nil
|
||||
}
|
||||
let imageViews = cell.mosaicImageViewContainer.imageViews
|
||||
guard index < imageViews.count else { return nil }
|
||||
let imageView = imageViews[index]
|
||||
return UITargetedPreview(view: imageView, parameters: UIPreviewParameters())
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
guard let previewableViewController = self as? MediaPreviewableViewController else { return }
|
||||
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return }
|
||||
guard let indexPath = configuration.indexPath, let index = configuration.index else { return }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return }
|
||||
let imageViews = cell.mosaicImageViewContainer.imageViews
|
||||
guard index < imageViews.count else { return }
|
||||
let imageView = imageViews[index]
|
||||
|
||||
let status = status(for: nil, indexPath: indexPath)
|
||||
let initialFrame: CGRect? = {
|
||||
guard let previewViewController = animator.previewViewController else { return nil }
|
||||
return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController)
|
||||
}()
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion { [weak self] in
|
||||
guard let self = self else { return }
|
||||
status
|
||||
//.delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
guard let status = (status?.reblog ?? status) else { return }
|
||||
|
||||
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
|
||||
statusObjectID: status.objectID,
|
||||
initialIndex: index,
|
||||
preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails()
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .mosaic(cell.mosaicImageViewContainer),
|
||||
previewableViewController: previewableViewController
|
||||
)
|
||||
pushTransitionItem.aspectRatio = {
|
||||
if let image = imageView.image {
|
||||
return image.size
|
||||
}
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
||||
guard index < media.count else { return nil }
|
||||
let meta = media[index].meta
|
||||
guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil }
|
||||
return CGSize(width: width, height: height)
|
||||
}()
|
||||
pushTransitionItem.sourceImageView = imageView
|
||||
pushTransitionItem.initialFrame = {
|
||||
if let initialFrame = initialFrame {
|
||||
return initialFrame
|
||||
}
|
||||
return imageView.superview!.convert(imageView.frame, to: nil)
|
||||
}()
|
||||
pushTransitionItem.image = {
|
||||
if let image = imageView.image {
|
||||
return image
|
||||
}
|
||||
if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count {
|
||||
return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
let mediaPreviewViewModel = MediaPreviewViewModel(
|
||||
context: self.context,
|
||||
meta: meta,
|
||||
pushTransitionItem: pushTransitionItem
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController))
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
|
||||
// hack to retrieve preview view frame in window
|
||||
fileprivate static func findContextMenuPreviewFrameInWindow(
|
||||
previewController: UIViewController
|
||||
) -> CGRect? {
|
||||
guard let window = previewController.view.window else { return nil }
|
||||
|
||||
let targetViews = window.subviews
|
||||
.map { $0.findSameSize(view: previewController.view) }
|
||||
.flatMap { $0 }
|
||||
for targetView in targetViews {
|
||||
guard let targetViewSuperview = targetView.superview else { continue }
|
||||
let frame = targetViewSuperview.convert(targetView.frame, to: nil)
|
||||
guard frame.origin.x > 0, frame.origin.y > 0 else { continue }
|
||||
return frame
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func findSameSize(view: UIView) -> [UIView] {
|
||||
var views: [UIView] = []
|
||||
|
||||
if view.bounds.size == bounds.size {
|
||||
views.append(self)
|
||||
}
|
||||
|
||||
for subview in subviews {
|
||||
let targetViews = subview.findSameSize(view: view)
|
||||
views.append(contentsOf: targetViews)
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -528,6 +528,64 @@ extension StatusProviderFacade {
|
|||
}
|
||||
}
|
||||
|
||||
extension StatusProviderFacade {
|
||||
static func coordinateToStatusMediaPreviewScene(provider: StatusProvider & MediaPreviewableViewController, cell: UITableViewCell, mosaicImageView: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
|
||||
provider.status(for: cell, indexPath: nil)
|
||||
.sink { [weak provider] status in
|
||||
guard let provider = provider else { return }
|
||||
guard let source = status else { return }
|
||||
|
||||
let status = source.reblog ?? source
|
||||
|
||||
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
|
||||
statusObjectID: status.objectID,
|
||||
initialIndex: index,
|
||||
preloadThumbnailImages: mosaicImageView.thumbnails()
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .mosaic(mosaicImageView),
|
||||
previewableViewController: provider
|
||||
)
|
||||
pushTransitionItem.aspectRatio = {
|
||||
if let image = imageView.image {
|
||||
return image.size
|
||||
}
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
||||
guard index < media.count else { return nil }
|
||||
let meta = media[index].meta
|
||||
guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil }
|
||||
return CGSize(width: width, height: height)
|
||||
}()
|
||||
pushTransitionItem.sourceImageView = imageView
|
||||
pushTransitionItem.initialFrame = {
|
||||
let initialFrame = imageView.superview!.convert(imageView.frame, to: nil)
|
||||
assert(initialFrame != .zero)
|
||||
return initialFrame
|
||||
}()
|
||||
pushTransitionItem.image = {
|
||||
if let image = imageView.image {
|
||||
return image
|
||||
}
|
||||
if index < mosaicImageView.blurhashOverlayImageViews.count {
|
||||
return mosaicImageView.blurhashOverlayImageViews[index].image
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
let mediaPreviewViewModel = MediaPreviewViewModel(
|
||||
context: provider.context,
|
||||
meta: meta,
|
||||
pushTransitionItem: pushTransitionItem
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController))
|
||||
}
|
||||
}
|
||||
.store(in: &provider.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusProviderFacade {
|
||||
enum Target {
|
||||
case primary // original status
|
||||
|
|
|
@ -9,12 +9,12 @@ import UIKit
|
|||
import AVKit
|
||||
|
||||
// Check List Last Updated
|
||||
// - HomeViewController: 2021/4/13
|
||||
// - FavoriteViewController: 2021/4/14
|
||||
// - HashtagTimelineViewController: 2021/4/8
|
||||
// - UserTimelineViewController: 2021/4/13
|
||||
// - ThreadViewController: 2021/4/13
|
||||
// * StatusTableViewControllerAspect: 2021/4/12
|
||||
// - HomeViewController: 2021/4/30
|
||||
// - FavoriteViewController: 2021/4/30
|
||||
// - HashtagTimelineViewController: 2021/4/30
|
||||
// - UserTimelineViewController: 2021/4/30
|
||||
// - ThreadViewController: 2021/4/30
|
||||
// * StatusTableViewControllerAspect: 2021/4/30
|
||||
|
||||
// (Fake) Aspect protocol to group common protocol extension implementations
|
||||
// Needs update related view controller when aspect interface changes
|
||||
|
@ -103,6 +103,38 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat
|
|||
}
|
||||
}
|
||||
|
||||
// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to display context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
}
|
||||
|
||||
// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu preview action
|
||||
func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching [C]
|
||||
|
||||
// [C1] aspectTableView(:prefetchRowsAt)
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo.";
|
||||
"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure";
|
||||
"Common.Alerts.ServerError.Title" = "Server Error";
|
||||
"Common.Alerts.SignOut.Confirm" = "Sign Out";
|
||||
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
|
||||
|
@ -30,6 +32,7 @@ Please check your internet connection.";
|
|||
"Common.Controls.Actions.Save" = "Save";
|
||||
"Common.Controls.Actions.SavePhoto" = "Save photo";
|
||||
"Common.Controls.Actions.SeeMore" = "See More";
|
||||
"Common.Controls.Actions.Settings" = "Settings";
|
||||
"Common.Controls.Actions.Share" = "Share";
|
||||
"Common.Controls.Actions.ShareUser" = "Share %@";
|
||||
"Common.Controls.Actions.SignIn" = "Sign In";
|
||||
|
|
|
@ -12,7 +12,7 @@ import Combine
|
|||
import GameplayKit
|
||||
import CoreData
|
||||
|
||||
class HashtagTimelineViewController: UIViewController, NeedsDependency {
|
||||
class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
|
@ -20,6 +20,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency {
|
|||
|
||||
var viewModel: HashtagTimelineViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let composeBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
|
||||
|
@ -222,6 +224,23 @@ extension HashtagTimelineViewController: UITableViewDelegate {
|
|||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||
|
|
|
@ -15,7 +15,7 @@ import GameplayKit
|
|||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
|
||||
final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var emptyView: UIStackView = {
|
||||
let emptyView = UIStackView()
|
||||
emptyView.axis = .vertical
|
||||
|
@ -376,6 +378,23 @@ extension HomeTimelineViewController: UITableViewDelegate {
|
|||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
//
|
||||
// MediaPreviewViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Pageboy
|
||||
|
||||
final class MediaPreviewViewController: UIViewController, NeedsDependency {
|
||||
|
||||
static let closeButtonSize = CGSize(width: 30, height: 30)
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: MediaPreviewViewModel!
|
||||
|
||||
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
let pagingViewConttroller = MediaPreviewPagingViewController()
|
||||
|
||||
let closeButtonBackground: UIVisualEffectView = {
|
||||
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
|
||||
backgroundView.alpha = 0.9
|
||||
backgroundView.layer.masksToBounds = true
|
||||
backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5
|
||||
return backgroundView
|
||||
}()
|
||||
|
||||
let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)))
|
||||
|
||||
let closeButton: UIButton = {
|
||||
let button = HighlightDimmableButton()
|
||||
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
|
||||
button.imageView?.tintColor = .label
|
||||
button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal)
|
||||
return button
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
|
||||
visualEffectView.frame = view.bounds
|
||||
view.addSubview(visualEffectView)
|
||||
|
||||
pagingViewConttroller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addChild(pagingViewConttroller)
|
||||
visualEffectView.contentView.addSubview(pagingViewConttroller.view)
|
||||
NSLayoutConstraint.activate([
|
||||
visualEffectView.topAnchor.constraint(equalTo: pagingViewConttroller.view.topAnchor),
|
||||
visualEffectView.bottomAnchor.constraint(equalTo: pagingViewConttroller.view.bottomAnchor),
|
||||
visualEffectView.leadingAnchor.constraint(equalTo: pagingViewConttroller.view.leadingAnchor),
|
||||
visualEffectView.trailingAnchor.constraint(equalTo: pagingViewConttroller.view.trailingAnchor),
|
||||
])
|
||||
pagingViewConttroller.didMove(toParent: self)
|
||||
|
||||
closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(closeButtonBackground)
|
||||
NSLayoutConstraint.activate([
|
||||
closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
|
||||
closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)
|
||||
])
|
||||
closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView)
|
||||
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton)
|
||||
NSLayoutConstraint.activate([
|
||||
closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor),
|
||||
closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor),
|
||||
closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor),
|
||||
closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
viewModel.mediaPreviewImageViewControllerDelegate = self
|
||||
|
||||
pagingViewConttroller.interPageSpacing = 10
|
||||
pagingViewConttroller.delegate = self
|
||||
pagingViewConttroller.dataSource = viewModel
|
||||
|
||||
closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside)
|
||||
|
||||
// bind view model
|
||||
viewModel.currentPage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] index in
|
||||
guard let self = self else { return }
|
||||
switch self.viewModel.pushTransitionItem.source {
|
||||
case .mosaic(let mosaicImageViewContainer):
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
mosaicImageViewContainer.setImageViews(alpha: 1)
|
||||
mosaicImageViewContainer.setImageView(alpha: 0, index: index)
|
||||
}
|
||||
case .profileAvatar, .profileBanner:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewViewController {
|
||||
|
||||
@objc private func closeButtonPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - MediaPreviewingViewController
|
||||
extension MediaPreviewViewController: MediaPreviewingViewController {
|
||||
|
||||
func isInteractiveDismissable() -> Bool {
|
||||
if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController {
|
||||
let previewImageView = mediaPreviewImageViewController.previewImageView
|
||||
// TODO: allow zooming pan dismiss
|
||||
guard previewImageView.zoomScale == previewImageView.minimumZoomScale else {
|
||||
return false
|
||||
}
|
||||
|
||||
let safeAreaInsets = previewImageView.safeAreaInsets
|
||||
let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false")
|
||||
return dismissable
|
||||
}
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDelegate
|
||||
extension MediaPreviewViewController: PageboyViewControllerDelegate {
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
willScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollTo position: CGPoint,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// update page control
|
||||
// pageControl.currentPage = index
|
||||
viewModel.currentPage.value = index
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didReloadWith currentViewController: UIViewController,
|
||||
currentPageIndex: PageboyViewController.PageIndex
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - MediaPreviewImageViewControllerDelegate
|
||||
extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
|
||||
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) {
|
||||
switch action {
|
||||
case .savePhoto:
|
||||
switch viewController.viewModel.item {
|
||||
case .status(let meta):
|
||||
context.photoLibraryService.saveImage(url: meta.url)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
guard let error = error as? PhotoLibraryService.PhotoLibraryError,
|
||||
case .noPermission = error else { return }
|
||||
let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message)
|
||||
self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
case .local(let meta):
|
||||
context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true)
|
||||
}
|
||||
case .share:
|
||||
let applicationActivities: [UIActivity] = [
|
||||
SafariActivity(sceneCoordinator: self.coordinator)
|
||||
]
|
||||
let activityViewController = UIActivityViewController(
|
||||
activityItems: viewController.viewModel.item.activityItems,
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView
|
||||
self.present(activityViewController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// MediaPreviewViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Pageboy
|
||||
|
||||
final class MediaPreviewViewModel: NSObject {
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let initialItem: PreviewItem
|
||||
weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate?
|
||||
let currentPage: CurrentValueSubject<Int, Never>
|
||||
|
||||
// output
|
||||
let pushTransitionItem: MediaPreviewTransitionItem
|
||||
let viewControllers: [UIViewController]
|
||||
|
||||
init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) {
|
||||
self.context = context
|
||||
self.initialItem = .status(meta)
|
||||
var viewControllers: [UIViewController] = []
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
managedObjectContext.performAndWait {
|
||||
let status = managedObjectContext.object(with: meta.statusObjectID) as! Status
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return }
|
||||
for (entity, image) in zip(media, meta.preloadThumbnailImages) {
|
||||
let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil }
|
||||
switch entity.type {
|
||||
case .image:
|
||||
guard let url = URL(string: entity.url) else { continue }
|
||||
let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail)
|
||||
let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
viewControllers.append(mediaPreviewImageViewController)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
self.viewControllers = viewControllers
|
||||
self.currentPage = CurrentValueSubject(meta.initialIndex)
|
||||
self.pushTransitionItem = pushTransitionItem
|
||||
super.init()
|
||||
}
|
||||
|
||||
init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) {
|
||||
self.context = context
|
||||
self.initialItem = .profileBanner(meta)
|
||||
var viewControllers: [UIViewController] = []
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
managedObjectContext.performAndWait {
|
||||
let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser
|
||||
let avatarURL = account.headerImageURLWithFallback(domain: account.domain)
|
||||
let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage)
|
||||
let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
viewControllers.append(mediaPreviewImageViewController)
|
||||
}
|
||||
self.viewControllers = viewControllers
|
||||
self.currentPage = CurrentValueSubject(0)
|
||||
self.pushTransitionItem = pushTransitionItem
|
||||
super.init()
|
||||
}
|
||||
|
||||
init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) {
|
||||
self.context = context
|
||||
self.initialItem = .profileAvatar(meta)
|
||||
var viewControllers: [UIViewController] = []
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
managedObjectContext.performAndWait {
|
||||
let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser
|
||||
let avatarURL = account.avatarImageURLWithFallback(domain: account.domain)
|
||||
let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage)
|
||||
let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta)
|
||||
let mediaPreviewImageViewController = MediaPreviewImageViewController()
|
||||
mediaPreviewImageViewController.viewModel = mediaPreviewImageModel
|
||||
viewControllers.append(mediaPreviewImageViewController)
|
||||
}
|
||||
self.viewControllers = viewControllers
|
||||
self.currentPage = CurrentValueSubject(0)
|
||||
self.pushTransitionItem = pushTransitionItem
|
||||
super.init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewViewModel {
|
||||
|
||||
enum PreviewItem {
|
||||
case status(StatusImagePreviewMeta)
|
||||
case profileAvatar(ProfileAvatarImagePreviewMeta)
|
||||
case profileBanner(ProfileBannerImagePreviewMeta)
|
||||
case local(LocalImagePreviewMeta)
|
||||
}
|
||||
|
||||
struct StatusImagePreviewMeta {
|
||||
let statusObjectID: NSManagedObjectID
|
||||
let initialIndex: Int
|
||||
let preloadThumbnailImages: [UIImage?]
|
||||
}
|
||||
|
||||
struct ProfileAvatarImagePreviewMeta {
|
||||
let accountObjectID: NSManagedObjectID
|
||||
let preloadThumbnailImage: UIImage?
|
||||
}
|
||||
|
||||
struct ProfileBannerImagePreviewMeta {
|
||||
let accountObjectID: NSManagedObjectID
|
||||
let preloadThumbnailImage: UIImage?
|
||||
}
|
||||
|
||||
struct LocalImagePreviewMeta {
|
||||
let image: UIImage
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDataSource
|
||||
extension MediaPreviewViewModel: PageboyViewControllerDataSource {
|
||||
|
||||
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
|
||||
return viewControllers.count
|
||||
}
|
||||
|
||||
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
|
||||
let viewController = viewControllers[index]
|
||||
if let mediaPreviewImageViewController = viewController as? MediaPreviewImageViewController {
|
||||
mediaPreviewImageViewController.delegate = mediaPreviewImageViewControllerDelegate
|
||||
}
|
||||
return viewController
|
||||
}
|
||||
|
||||
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
|
||||
guard case let .status(meta) = initialItem else { return nil }
|
||||
return .at(index: meta.initialIndex)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
//
|
||||
// MediaPreviewImageView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
|
||||
final class MediaPreviewImageView: UIScrollView {
|
||||
|
||||
let imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.isUserInteractionEnabled = true
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let doubleTapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer()
|
||||
tapGestureRecognizer.numberOfTapsRequired = 2
|
||||
return tapGestureRecognizer
|
||||
}()
|
||||
|
||||
private var containerFrame: CGRect?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageView {
|
||||
|
||||
private func _init() {
|
||||
isUserInteractionEnabled = true
|
||||
showsVerticalScrollIndicator = false
|
||||
showsHorizontalScrollIndicator = false
|
||||
|
||||
bouncesZoom = true
|
||||
minimumZoomScale = 1.0
|
||||
maximumZoomScale = 4.0
|
||||
|
||||
addSubview(imageView)
|
||||
|
||||
doubleTapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageView.doubleTapGestureRecognizerHandler(_:)))
|
||||
imageView.addGestureRecognizer(doubleTapGestureRecognizer)
|
||||
|
||||
delegate = self
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let image = imageView.image else { return }
|
||||
setup(image: image, container: self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageView {
|
||||
|
||||
@objc private func doubleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
let middleZoomScale = 0.5 * maximumZoomScale
|
||||
if zoomScale >= middleZoomScale {
|
||||
setZoomScale(minimumZoomScale, animated: true)
|
||||
} else {
|
||||
let center = sender.location(in: imageView)
|
||||
let zoomRect: CGRect = {
|
||||
let width = bounds.width / middleZoomScale
|
||||
let height = bounds.height / middleZoomScale
|
||||
return CGRect(
|
||||
x: center.x - 0.5 * width,
|
||||
y: center.y - 0.5 * height,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
}()
|
||||
zoom(to: zoomRect, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageView {
|
||||
|
||||
func setup(image: UIImage, container: UIView, forceUpdate: Bool = false) {
|
||||
guard image.size.width > 0, image.size.height > 0 else { return }
|
||||
guard container.bounds.width > 0, container.bounds.height > 0 else { return }
|
||||
|
||||
// do not setup when frame not change except force update
|
||||
if containerFrame == container.frame && !forceUpdate {
|
||||
return
|
||||
}
|
||||
containerFrame = container.frame
|
||||
|
||||
// reset to normal
|
||||
zoomScale = minimumZoomScale
|
||||
|
||||
let imageViewSize = AVMakeRect(aspectRatio: image.size, insideRect: container.bounds).size
|
||||
let imageContentInset: UIEdgeInsets = {
|
||||
if imageViewSize.width == container.bounds.width {
|
||||
return UIEdgeInsets(top: 0.5 * (container.bounds.height - imageViewSize.height), left: 0, bottom: 0, right: 0)
|
||||
} else {
|
||||
return UIEdgeInsets(top: 0, left: 0.5 * (container.bounds.width - imageViewSize.width), bottom: 0, right: 0)
|
||||
}
|
||||
}()
|
||||
imageView.frame = CGRect(origin: .zero, size: imageViewSize)
|
||||
imageView.image = image
|
||||
contentSize = imageViewSize
|
||||
contentInset = imageContentInset
|
||||
|
||||
centerScrollViewContents()
|
||||
contentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top)
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup image for container %s", ((#file as NSString).lastPathComponent), #line, #function, container.frame.debugDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension MediaPreviewImageView: UIScrollViewDelegate {
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
centerScrollViewContents()
|
||||
|
||||
// set bounce when zoom in
|
||||
alwaysBounceVertical = zoomScale > minimumZoomScale
|
||||
}
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return imageView
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Ref: https://stackoverflow.com/questions/14069571/keep-zoomable-image-in-center-of-uiscrollview
|
||||
extension MediaPreviewImageView {
|
||||
|
||||
private var scrollViewVisibleSize: CGSize {
|
||||
let contentInset = self.contentInset
|
||||
let scrollViewSize = bounds.standardized.size
|
||||
let width = scrollViewSize.width - contentInset.left - contentInset.right
|
||||
let height = scrollViewSize.height - contentInset.top - contentInset.bottom
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
private var scrollViewCenter: CGPoint {
|
||||
let scrollViewSize = self.scrollViewVisibleSize
|
||||
return CGPoint(x: scrollViewSize.width / 2.0,
|
||||
y: scrollViewSize.height / 2.0)
|
||||
}
|
||||
|
||||
private func centerScrollViewContents() {
|
||||
guard let image = imageView.image else { return }
|
||||
|
||||
let imageViewSize = imageView.frame.size
|
||||
let imageSize = image.size
|
||||
|
||||
var realImageSize: CGSize
|
||||
if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height {
|
||||
realImageSize = CGSize(width: imageViewSize.width,
|
||||
height: imageViewSize.width / imageSize.width * imageSize.height)
|
||||
} else {
|
||||
realImageSize = CGSize(width: imageViewSize.height / imageSize.height * imageSize.width,
|
||||
height: imageViewSize.height)
|
||||
}
|
||||
|
||||
var frame = CGRect.zero
|
||||
frame.size = realImageSize
|
||||
imageView.frame = frame
|
||||
|
||||
let screenSize = self.frame.size
|
||||
let offsetX = screenSize.width > realImageSize.width ? (screenSize.width - realImageSize.width) / 2 : 0
|
||||
let offsetY = screenSize.height > realImageSize.height ? (screenSize.height - realImageSize.height) / 2 : 0
|
||||
contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: offsetY, right: offsetX)
|
||||
|
||||
// The scroll view has zoomed, so you need to re-center the contents
|
||||
let scrollViewSize = scrollViewVisibleSize
|
||||
|
||||
// First assume that image center coincides with the contents box center.
|
||||
// This is correct when the image is bigger than scrollView due to zoom
|
||||
var imageCenter = CGPoint(x: contentSize.width / 2.0,
|
||||
y: contentSize.height / 2.0)
|
||||
|
||||
let center = scrollViewCenter
|
||||
|
||||
//if image is smaller than the scrollView visible size - fix the image center accordingly
|
||||
if contentSize.width < scrollViewSize.width {
|
||||
imageCenter.x = center.x
|
||||
}
|
||||
|
||||
if contentSize.height < scrollViewSize.height {
|
||||
imageCenter.y = center.y
|
||||
}
|
||||
|
||||
imageView.center = imageCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
//
|
||||
// MediaPreviewImageViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
protocol MediaPreviewImageViewControllerDelegate: AnyObject {
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer)
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer)
|
||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction)
|
||||
}
|
||||
|
||||
final class MediaPreviewImageViewController: UIViewController {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
var viewModel: MediaPreviewImageViewModel!
|
||||
weak var delegate: MediaPreviewImageViewControllerDelegate?
|
||||
|
||||
// let progressBarView = ProgressBarView()
|
||||
let previewImageView = MediaPreviewImageView()
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
let longPressGestureRecognizer = UILongPressGestureRecognizer()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
previewImageView.imageView.af.cancelImageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaPreviewImageViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// progressBarView.tintColor = .white
|
||||
// progressBarView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(progressBarView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// progressBarView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
// progressBarView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
// progressBarView.widthAnchor.constraint(equalToConstant: 120),
|
||||
// progressBarView.heightAnchor.constraint(equalToConstant: 44),
|
||||
// ])
|
||||
|
||||
previewImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(previewImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
previewImageView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
previewImageView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:)))
|
||||
longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:)))
|
||||
tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer)
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
previewImageView.addGestureRecognizer(tapGestureRecognizer)
|
||||
previewImageView.addGestureRecognizer(longPressGestureRecognizer)
|
||||
|
||||
let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
previewImageView.addInteraction(previewImageViewContextMenuInteraction)
|
||||
|
||||
viewModel.image
|
||||
.receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state)
|
||||
.sink { [weak self] image in
|
||||
guard let self = self else { return }
|
||||
guard let image = image else { return }
|
||||
self.previewImageView.imageView.image = image
|
||||
self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageViewController {
|
||||
|
||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.mediaPreviewImageViewController(self, tapGestureRecognizerDidTrigger: sender)
|
||||
}
|
||||
|
||||
@objc private func longPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.mediaPreviewImageViewController(self, longPressGestureRecognizerDidTrigger: sender)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIContextMenuInteractionDelegate
|
||||
extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in
|
||||
return nil
|
||||
}
|
||||
|
||||
let saveAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto)
|
||||
}
|
||||
|
||||
let shareAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share)
|
||||
}
|
||||
|
||||
let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
||||
saveAction,
|
||||
shareAction
|
||||
])
|
||||
}
|
||||
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
// set preview view
|
||||
return UITargetedPreview(view: previewImageView.imageView)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageViewController {
|
||||
enum ContextMenuAction {
|
||||
case savePhoto
|
||||
case share
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
// MediaPreviewImageViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import AlamofireImage
|
||||
|
||||
class MediaPreviewImageViewModel {
|
||||
|
||||
// input
|
||||
let item: ImagePreviewItem
|
||||
|
||||
// output
|
||||
let image: CurrentValueSubject<UIImage?, Never>
|
||||
|
||||
init(meta: RemoteImagePreviewMeta) {
|
||||
self.item = .status(meta)
|
||||
self.image = CurrentValueSubject(meta.thumbnail)
|
||||
|
||||
let url = meta.url
|
||||
ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
switch response.result {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription)
|
||||
case .success(let image):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription)
|
||||
self.image.value = image
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init(meta: LocalImagePreviewMeta) {
|
||||
self.item = .local(meta)
|
||||
self.image = CurrentValueSubject(meta.image)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewImageViewModel {
|
||||
enum ImagePreviewItem {
|
||||
case status(RemoteImagePreviewMeta)
|
||||
case local(LocalImagePreviewMeta)
|
||||
|
||||
var activityItems: [Any] {
|
||||
var items: [Any] = []
|
||||
|
||||
switch self {
|
||||
case .status(let meta):
|
||||
items.append(meta.url)
|
||||
case .local(let meta):
|
||||
items.append(meta.image)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteImagePreviewMeta {
|
||||
let url: URL
|
||||
let thumbnail: UIImage?
|
||||
}
|
||||
|
||||
struct LocalImagePreviewMeta {
|
||||
let image: UIImage
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// MediaPreviewPagingViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pageboy
|
||||
|
||||
final class MediaPreviewPagingViewController: PageboyViewController { }
|
|
@ -14,7 +14,7 @@ import AVKit
|
|||
import Combine
|
||||
import GameplayKit
|
||||
|
||||
final class FavoriteViewController: UIViewController, NeedsDependency {
|
||||
final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -22,6 +22,8 @@ final class FavoriteViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: FavoriteViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
|
@ -118,6 +120,22 @@ extension FavoriteViewController: UITableViewDelegate {
|
|||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -150,8 +150,7 @@ extension ProfileHeaderViewController {
|
|||
with: AvatarConfigurableViewConfiguration(
|
||||
avatarImageURL: image == nil ? url : nil, // set only when image empty
|
||||
placeholderImage: image,
|
||||
borderColor: .white,
|
||||
borderWidth: 2
|
||||
keepImageCorner: true // fit preview transitioning
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -329,7 +328,9 @@ extension ProfileHeaderViewController {
|
|||
let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
|
||||
let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
|
||||
let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
|
||||
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset))
|
||||
let transformY = max(0, titleViewContentOffset)
|
||||
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
|
||||
viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height
|
||||
|
||||
if viewModel.viewDidAppear.value {
|
||||
viewModel.isTitleViewContentOffsetSet.value = true
|
||||
|
@ -348,6 +349,7 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
|
||||
private func setProfileBannerFade(alpha: CGFloat) {
|
||||
profileHeaderView.avatarImageViewBackgroundView.alpha = alpha
|
||||
profileHeaderView.avatarImageView.alpha = alpha
|
||||
profileHeaderView.editAvatarBackgroundView.alpha = alpha
|
||||
profileHeaderView.nameTextFieldBackgroundView.alpha = alpha
|
||||
|
|
|
@ -24,6 +24,7 @@ final class ProfileHeaderViewModel {
|
|||
// output
|
||||
let displayProfileInfo = ProfileInfo()
|
||||
let editProfileInfo = ProfileInfo()
|
||||
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
|
|
|
@ -10,7 +10,9 @@ import UIKit
|
|||
import ActiveLabel
|
||||
import TwitterTextEditor
|
||||
|
||||
protocol ProfileHeaderViewDelegate: class {
|
||||
protocol ProfileHeaderViewDelegate: AnyObject {
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
|
||||
|
||||
|
@ -23,6 +25,8 @@ final class ProfileHeaderView: UIView {
|
|||
|
||||
static let avatarImageViewSize = CGSize(width: 56, height: 56)
|
||||
static let avatarImageViewCornerRadius: CGFloat = 6
|
||||
static let avatarImageViewBorderColor = UIColor.white
|
||||
static let avatarImageViewBorderWidth: CGFloat = 2
|
||||
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
|
||||
static let bannerImageViewPlaceholderColor = UIColor.systemGray
|
||||
|
||||
|
@ -40,6 +44,7 @@ final class ProfileHeaderView: UIView {
|
|||
imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
|
||||
imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.isUserInteractionEnabled = true
|
||||
// #if DEBUG
|
||||
// imageView.image = .placeholder(color: .red)
|
||||
// #endif
|
||||
|
@ -51,6 +56,16 @@ final class ProfileHeaderView: UIView {
|
|||
return overlayView
|
||||
}()
|
||||
|
||||
let avatarImageViewBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.masksToBounds = true
|
||||
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.layer.borderColor = ProfileHeaderView.avatarImageViewBorderColor.cgColor
|
||||
view.layer.borderWidth = ProfileHeaderView.avatarImageViewBorderWidth
|
||||
return view
|
||||
}()
|
||||
|
||||
let avatarImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
let placeholderImage = UIImage
|
||||
|
@ -188,6 +203,15 @@ extension ProfileHeaderView {
|
|||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
|
||||
])
|
||||
|
||||
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarImageView.addSubview(editAvatarBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -313,6 +337,14 @@ extension ProfileHeaderView {
|
|||
|
||||
bioActiveLabel.delegate = self
|
||||
|
||||
let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer)
|
||||
avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:)))
|
||||
|
||||
let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
bannerImageView.addGestureRecognizer(bannerImageViewSingleTapGestureRecognizer)
|
||||
bannerImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.bannerImageViewDidPressed(_:)))
|
||||
|
||||
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
|
||||
|
||||
configure(state: .normal)
|
||||
|
@ -372,6 +404,16 @@ extension ProfileHeaderView {
|
|||
assert(sender === relationshipActionButton)
|
||||
delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton)
|
||||
}
|
||||
|
||||
@objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView)
|
||||
}
|
||||
|
||||
@objc private func bannerImageViewDidPressed(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.profileHeaderView(self, bannerImageViewDidPressed: bannerImageView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ActiveLabelDelegate
|
||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
|||
import Combine
|
||||
import ActiveLabel
|
||||
|
||||
final class ProfileViewController: UIViewController, NeedsDependency {
|
||||
final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -18,6 +18,8 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ProfileViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
|
@ -170,13 +172,14 @@ extension ProfileViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest3 (
|
||||
Publishers.CombineLatest4 (
|
||||
viewModel.suspended.eraseToAnyPublisher(),
|
||||
profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(),
|
||||
editingAndUpdatingPublisher.eraseToAnyPublisher(),
|
||||
barButtonItemHiddenPublisher.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] suspended, tuple1, tuple2 in
|
||||
.sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in
|
||||
guard let self = self else { return }
|
||||
let (isEditing, _) = tuple1
|
||||
let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2
|
||||
|
@ -195,6 +198,10 @@ extension ProfileViewController {
|
|||
return
|
||||
}
|
||||
|
||||
guard !isTitleViewDisplaying else {
|
||||
return
|
||||
}
|
||||
|
||||
guard isMeBarButtonItemsHidden else {
|
||||
items.append(self.settingBarButtonItem)
|
||||
items.append(self.shareBarButtonItem)
|
||||
|
@ -645,6 +652,72 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate {
|
|||
// MARK: - ProfileHeaderViewDelegate
|
||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) {
|
||||
guard let mastodonUser = viewModel.mastodonUser.value else { return }
|
||||
guard let avatar = imageView.image else { return }
|
||||
|
||||
let meta = MediaPreviewViewModel.ProfileAvatarImagePreviewMeta(
|
||||
accountObjectID: mastodonUser.objectID,
|
||||
preloadThumbnailImage: avatar
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .profileAvatar(profileHeaderView),
|
||||
previewableViewController: self
|
||||
)
|
||||
pushTransitionItem.aspectRatio = CGSize(width: 100, height: 100)
|
||||
pushTransitionItem.sourceImageView = imageView
|
||||
pushTransitionItem.sourceImageViewCornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
||||
pushTransitionItem.initialFrame = {
|
||||
let initialFrame = imageView.superview!.convert(imageView.frame, to: nil)
|
||||
assert(initialFrame != .zero)
|
||||
return initialFrame
|
||||
}()
|
||||
pushTransitionItem.image = avatar
|
||||
|
||||
let mediaPreviewViewModel = MediaPreviewViewModel(
|
||||
context: context,
|
||||
meta: meta,
|
||||
pushTransitionItem: pushTransitionItem
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController))
|
||||
}
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) {
|
||||
// not preview header banner when editing
|
||||
guard !viewModel.isEditing.value else { return }
|
||||
|
||||
guard let mastodonUser = viewModel.mastodonUser.value else { return }
|
||||
guard let header = imageView.image else { return }
|
||||
|
||||
let meta = MediaPreviewViewModel.ProfileBannerImagePreviewMeta(
|
||||
accountObjectID: mastodonUser.objectID,
|
||||
preloadThumbnailImage: header
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .profileBanner(profileHeaderView),
|
||||
previewableViewController: self
|
||||
)
|
||||
pushTransitionItem.aspectRatio = header.size
|
||||
pushTransitionItem.sourceImageView = imageView
|
||||
pushTransitionItem.initialFrame = {
|
||||
let initialFrame = imageView.superview!.convert(imageView.frame, to: nil)
|
||||
assert(initialFrame != .zero)
|
||||
return initialFrame
|
||||
}()
|
||||
pushTransitionItem.image = header
|
||||
|
||||
let mediaPreviewViewModel = MediaPreviewViewModel(
|
||||
context: context,
|
||||
meta: meta,
|
||||
pushTransitionItem: pushTransitionItem
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController))
|
||||
}
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
|
||||
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
|
||||
if relationshipActionSet.contains(.edit) {
|
||||
|
|
|
@ -13,7 +13,7 @@ import CoreDataStack
|
|||
import GameplayKit
|
||||
|
||||
// TODO: adopt MediaPreviewableViewController
|
||||
final class UserTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -21,7 +21,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: UserTimelineViewModel!
|
||||
|
||||
// let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
|
@ -128,6 +128,22 @@ extension UserTimelineViewController: UITableViewDelegate {
|
|||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -13,13 +13,15 @@ import GameplayKit
|
|||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class PublicTimelineViewController: UIViewController, NeedsDependency {
|
||||
final class PublicTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: PublicTimelineViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// ContextMenuImagePreviewViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class ContextMenuImagePreviewViewController: UIViewController {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var viewModel: ContextMenuImagePreviewViewModel!
|
||||
|
||||
let imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.masksToBounds = true
|
||||
return imageView
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension ContextMenuImagePreviewViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
imageView.image = viewModel.thumbnail
|
||||
|
||||
let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds)
|
||||
preferredContentSize = frame.size
|
||||
|
||||
viewModel.url
|
||||
.sink { [weak self] url in
|
||||
guard let self = self else { return }
|
||||
guard let url = url else { return }
|
||||
self.imageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: self.viewModel.thumbnail,
|
||||
imageTransition: .crossDissolve(0.2),
|
||||
runImageTransitionIfCached: true,
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// ContextMenuImagePreviewViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class ContextMenuImagePreviewViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let aspectRatio: CGSize
|
||||
let thumbnail: UIImage?
|
||||
let url = CurrentValueSubject<URL?, Never>(nil)
|
||||
|
||||
init(aspectRatio: CGSize, thumbnail: UIImage?) {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.thumbnail = thumbnail
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// TimelineTableViewCellContextMenuConfiguration.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue
|
||||
final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration {
|
||||
|
||||
var indexPath: IndexPath?
|
||||
var index: Int?
|
||||
|
||||
}
|
|
@ -9,14 +9,14 @@ import os.log
|
|||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
|
||||
protocol MosaicImageViewContainerPresentable: class {
|
||||
protocol MosaicImageViewContainerPresentable: AnyObject {
|
||||
var mosaicImageViewContainer: MosaicImageViewContainer { get }
|
||||
var isRevealing: Bool { get }
|
||||
}
|
||||
|
||||
protocol MosaicImageViewContainerDelegate: class {
|
||||
protocol MosaicImageViewContainerDelegate: AnyObject {
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
|
||||
}
|
||||
|
||||
final class MosaicImageViewContainer: UIView {
|
||||
|
@ -296,6 +296,41 @@ extension MosaicImageViewContainer {
|
|||
|
||||
}
|
||||
|
||||
// FIXME: refactor blurhash image and preview image
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
func setImageViews(alpha: CGFloat) {
|
||||
// blurhashOverlayImageViews.forEach { $0.alpha = alpha }
|
||||
imageViews.forEach { $0.alpha = alpha }
|
||||
}
|
||||
|
||||
func setImageView(alpha: CGFloat, index: Int) {
|
||||
// if index < blurhashOverlayImageViews.count {
|
||||
// blurhashOverlayImageViews[index].alpha = alpha
|
||||
// }
|
||||
if index < imageViews.count {
|
||||
imageViews[index].alpha = alpha
|
||||
}
|
||||
}
|
||||
|
||||
func thumbnail(at index: Int) -> UIImage? {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return nil }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
guard index < tuples.count else { return nil }
|
||||
let tuple = tuples[index]
|
||||
return tuple.1.image ?? tuple.0.image
|
||||
}
|
||||
|
||||
func thumbnails() -> [UIImage?] {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return [] }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in
|
||||
return imageView.image ?? blurhashOverlayImageView.image
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
@objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import Combine
|
|||
import CoreData
|
||||
import AVKit
|
||||
|
||||
final class ThreadViewController: UIViewController, NeedsDependency {
|
||||
final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -19,6 +19,8 @@ final class ThreadViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ThreadViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||
|
||||
let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem(
|
||||
|
@ -149,6 +151,22 @@ extension ThreadViewController: UITableViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -0,0 +1,391 @@
|
|||
//
|
||||
// MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import func AVFoundation.AVMakeRect
|
||||
|
||||
final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning {
|
||||
|
||||
let transitionItem: MediaPreviewTransitionItem
|
||||
let panGestureRecognizer: UIPanGestureRecognizer
|
||||
|
||||
private var isTransitionContextFinish = false
|
||||
|
||||
private var popInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero)
|
||||
private var itemInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero)
|
||||
|
||||
init(operation: UINavigationController.Operation, transitionItem: MediaPreviewTransitionItem, panGestureRecognizer: UIPanGestureRecognizer) {
|
||||
self.transitionItem = transitionItem
|
||||
self.panGestureRecognizer = panGestureRecognizer
|
||||
super.init(operation: operation)
|
||||
}
|
||||
|
||||
class func animator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator {
|
||||
let timingParameters = UISpringTimingParameters(mass: 4.0, stiffness: 1300, damping: 180, initialVelocity: initialVelocity)
|
||||
return UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerAnimatedTransitioning
|
||||
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||
|
||||
override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
super.animateTransition(using: transitionContext)
|
||||
|
||||
switch operation {
|
||||
case .push: pushTransition(using: transitionContext).startAnimation()
|
||||
case .pop: popTransition(using: transitionContext).startAnimation()
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
||||
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? MediaPreviewViewController,
|
||||
let toView = transitionContext.view(forKey: .to) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
toView.frame = toViewEndFrame
|
||||
toView.alpha = 0
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
// set to image hidden
|
||||
toVC.pagingViewConttroller.view.alpha = 0
|
||||
// set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController`
|
||||
transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value)
|
||||
|
||||
// Set transition image view
|
||||
assert(transitionItem.initialFrame != nil)
|
||||
let initialFrame = transitionItem.initialFrame ?? toViewEndFrame
|
||||
let transitionTargetFrame: CGRect = {
|
||||
let aspectRatio = transitionItem.aspectRatio ?? CGSize(width: initialFrame.width, height: initialFrame.height)
|
||||
return AVMakeRect(aspectRatio: aspectRatio, insideRect: toView.bounds)
|
||||
}()
|
||||
let transitionImageView: UIImageView = {
|
||||
let imageView = UIImageView(frame: transitionContext.containerView.convert(initialFrame, from: nil))
|
||||
imageView.clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.isUserInteractionEnabled = false
|
||||
imageView.image = transitionItem.image
|
||||
return imageView
|
||||
}()
|
||||
transitionItem.targetFrame = transitionTargetFrame
|
||||
transitionItem.imageView = transitionImageView
|
||||
transitionContext.containerView.addSubview(transitionImageView)
|
||||
|
||||
let animator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero)
|
||||
|
||||
animator.addAnimations {
|
||||
transitionImageView.frame = transitionTargetFrame
|
||||
toView.alpha = 1
|
||||
}
|
||||
|
||||
animator.addCompletion { position in
|
||||
toVC.pagingViewConttroller.view.alpha = 1
|
||||
transitionImageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
|
||||
return animator
|
||||
}
|
||||
|
||||
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewConttroller.currentIndex else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// assert view hierarchy not change
|
||||
let toVC = transitionItem.previewableViewController
|
||||
let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index)
|
||||
|
||||
let imageView = mediaPreviewImageViewController.previewImageView.imageView
|
||||
let _snapshot: UIView? = {
|
||||
transitionItem.snapshotRaw = imageView
|
||||
let snapshot = imageView.snapshotView(afterScreenUpdates: false)
|
||||
snapshot?.clipsToBounds = true
|
||||
snapshot?.contentMode = .scaleAspectFill
|
||||
return snapshot
|
||||
}()
|
||||
guard let snapshot = _snapshot else {
|
||||
transitionContext.completeTransition(false)
|
||||
fatalError()
|
||||
}
|
||||
mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView)
|
||||
|
||||
snapshot.center = transitionContext.containerView.center
|
||||
|
||||
transitionItem.imageView = imageView
|
||||
transitionItem.snapshotTransitioning = snapshot
|
||||
transitionItem.initialFrame = snapshot.frame
|
||||
transitionItem.targetFrame = targetFrame
|
||||
|
||||
// disable interaction
|
||||
fromVC.pagingViewConttroller.isUserInteractionEnabled = false
|
||||
|
||||
let animator = popInteractiveTransitionAnimator
|
||||
|
||||
self.transitionItem.snapshotRaw?.alpha = 0.0
|
||||
animator.addAnimations {
|
||||
if let targetFrame = targetFrame {
|
||||
self.transitionItem.snapshotTransitioning?.frame = targetFrame
|
||||
} else {
|
||||
fromView.alpha = 0
|
||||
}
|
||||
self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 }
|
||||
fromVC.closeButtonBackground.alpha = 0
|
||||
fromVC.visualEffectView.effect = nil
|
||||
}
|
||||
|
||||
animator.addCompletion { position in
|
||||
self.transitionItem.snapshotTransitioning?.removeFromSuperview()
|
||||
self.transitionItem.source.updateAppearance(position: position, index: nil)
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
|
||||
return animator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerInteractiveTransitioning
|
||||
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||
|
||||
override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
|
||||
super.startInteractiveTransition(transitionContext)
|
||||
|
||||
switch operation {
|
||||
case .pop:
|
||||
// Note: change item.imageView transform via pan gesture
|
||||
panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:)))
|
||||
popInteractiveTransition(using: transitionContext)
|
||||
default:
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController,
|
||||
let fromView = transitionContext.view(forKey: .from),
|
||||
let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController,
|
||||
let index = fromVC.pagingViewConttroller.currentIndex else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// assert view hierarchy not change
|
||||
let toVC = transitionItem.previewableViewController
|
||||
let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index)
|
||||
|
||||
let imageView = mediaPreviewImageViewController.previewImageView.imageView
|
||||
let _snapshot: UIView? = {
|
||||
transitionItem.snapshotRaw = imageView
|
||||
let snapshot = imageView.snapshotView(afterScreenUpdates: false)
|
||||
snapshot?.clipsToBounds = true
|
||||
snapshot?.contentMode = .scaleAspectFill
|
||||
return snapshot
|
||||
}()
|
||||
guard let snapshot = _snapshot else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView)
|
||||
|
||||
snapshot.center = transitionContext.containerView.center
|
||||
|
||||
transitionItem.imageView = imageView
|
||||
transitionItem.snapshotTransitioning = snapshot
|
||||
transitionItem.initialFrame = snapshot.frame
|
||||
transitionItem.targetFrame = targetFrame ?? snapshot.frame
|
||||
|
||||
// disable interaction
|
||||
fromVC.pagingViewConttroller.isUserInteractionEnabled = false
|
||||
|
||||
let animator = popInteractiveTransitionAnimator
|
||||
|
||||
let blurEffect = fromVC.visualEffectView.effect
|
||||
self.transitionItem.snapshotRaw?.alpha = 0.0
|
||||
|
||||
animator.addAnimations {
|
||||
switch self.transitionItem.source {
|
||||
case .profileBanner:
|
||||
self.transitionItem.snapshotTransitioning?.alpha = 0.4
|
||||
default:
|
||||
break
|
||||
}
|
||||
fromVC.closeButtonBackground.alpha = 0
|
||||
fromVC.visualEffectView.effect = nil
|
||||
self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 }
|
||||
}
|
||||
|
||||
animator.addCompletion { position in
|
||||
fromVC.pagingViewConttroller.isUserInteractionEnabled = true
|
||||
fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1
|
||||
self.transitionItem.imageView?.isHidden = position == .end
|
||||
self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0
|
||||
self.transitionItem.snapshotTransitioning?.removeFromSuperview()
|
||||
if position == .end {
|
||||
// reset appearance
|
||||
self.transitionItem.source.updateAppearance(position: position, index: nil)
|
||||
}
|
||||
fromVC.visualEffectView.effect = position == .end ? nil : blurEffect
|
||||
transitionContext.completeTransition(position == .end)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
||||
|
||||
@objc func updatePanGestureInteractive(_ sender: UIPanGestureRecognizer) {
|
||||
guard !isTransitionContextFinish else { return } // do not accept transition abort
|
||||
|
||||
switch sender.state {
|
||||
case .began, .changed:
|
||||
let translation = sender.translation(in: transitionContext.containerView)
|
||||
let percent = popInteractiveTransitionAnimator.fractionComplete + progressStep(for: translation)
|
||||
popInteractiveTransitionAnimator.fractionComplete = percent
|
||||
transitionContext.updateInteractiveTransition(percent)
|
||||
updateTransitionItemPosition(of: translation)
|
||||
|
||||
// Reset translation to zero
|
||||
sender.setTranslation(CGPoint.zero, in: transitionContext.containerView)
|
||||
case .ended, .cancelled:
|
||||
let targetPosition = completionPosition()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: target position: %s", ((#file as NSString).lastPathComponent), #line, #function, targetPosition == .end ? "end" : "start")
|
||||
isTransitionContextFinish = true
|
||||
animate(targetPosition)
|
||||
|
||||
targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector {
|
||||
guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else {
|
||||
return CGVector.zero
|
||||
}
|
||||
|
||||
let dx = abs(targetFrame.midX - currentFrame.midX)
|
||||
let dy = abs(targetFrame.midY - currentFrame.midY)
|
||||
|
||||
guard dx > 0.0 && dy > 0.0 else {
|
||||
return CGVector.zero
|
||||
}
|
||||
|
||||
let range = CGFloat(35.0)
|
||||
let clippedVx = clip(-range, range, velocity.x / dx)
|
||||
let clippedVy = clip(-range, range, velocity.y / dy)
|
||||
return CGVector(dx: clippedVx, dy: clippedVy)
|
||||
}
|
||||
|
||||
private func completionPosition() -> UIViewAnimatingPosition {
|
||||
let completionThreshold: CGFloat = 0.33
|
||||
let flickMagnitude: CGFloat = 1200 // pts/sec
|
||||
let velocity = panGestureRecognizer.velocity(in: transitionContext.containerView).vector
|
||||
let isFlick = (velocity.magnitude > flickMagnitude)
|
||||
let isFlickDown = isFlick && (velocity.dy > 0.0)
|
||||
let isFlickUp = isFlick && (velocity.dy < 0.0)
|
||||
|
||||
if (operation == .push && isFlickUp) || (operation == .pop && isFlickDown) {
|
||||
return .end
|
||||
} else if (operation == .push && isFlickDown) || (operation == .pop && isFlickUp) {
|
||||
return .start
|
||||
} else if popInteractiveTransitionAnimator.fractionComplete > completionThreshold {
|
||||
return .end
|
||||
} else {
|
||||
return .start
|
||||
}
|
||||
}
|
||||
|
||||
// Create item animator and start it
|
||||
func animate(_ toPosition: UIViewAnimatingPosition) {
|
||||
// Create a property animator to animate each image's frame change
|
||||
let gestureVelocity = panGestureRecognizer.velocity(in: transitionContext.containerView)
|
||||
let velocity = convert(gestureVelocity, for: transitionItem)
|
||||
let itemAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: velocity)
|
||||
|
||||
itemAnimator.addAnimations {
|
||||
if toPosition == .end {
|
||||
switch self.transitionItem.source {
|
||||
case .profileBanner where toPosition == .end:
|
||||
// fade transition for banner
|
||||
self.transitionItem.snapshotTransitioning?.alpha = 0
|
||||
default:
|
||||
if let targetFrame = self.transitionItem.targetFrame {
|
||||
self.transitionItem.snapshotTransitioning?.frame = targetFrame
|
||||
} else {
|
||||
self.transitionItem.snapshotTransitioning?.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
if let initialFrame = self.transitionItem.initialFrame {
|
||||
self.transitionItem.snapshotTransitioning?.frame = initialFrame
|
||||
} else {
|
||||
self.transitionItem.snapshotTransitioning?.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the property animator and keep track of it
|
||||
self.itemInteractiveTransitionAnimator = itemAnimator
|
||||
itemAnimator.startAnimation()
|
||||
|
||||
// Reverse the transition animator if we are returning to the start position
|
||||
popInteractiveTransitionAnimator.isReversed = (toPosition == .start)
|
||||
|
||||
if popInteractiveTransitionAnimator.state == .inactive {
|
||||
popInteractiveTransitionAnimator.startAnimation()
|
||||
} else {
|
||||
let durationFactor = CGFloat(itemAnimator.duration / popInteractiveTransitionAnimator.duration)
|
||||
popInteractiveTransitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor)
|
||||
}
|
||||
}
|
||||
|
||||
private func progressStep(for translation: CGPoint) -> CGFloat {
|
||||
return (operation == .push ? -1.0 : 1.0) * translation.y / transitionContext.containerView.bounds.midY
|
||||
}
|
||||
|
||||
private func updateTransitionItemPosition(of translation: CGPoint) {
|
||||
let progress = progressStep(for: translation)
|
||||
|
||||
let initialSize = transitionItem.initialFrame!.size
|
||||
guard initialSize != .zero else { return }
|
||||
// assert(initialSize != .zero)
|
||||
|
||||
guard let snapshot = transitionItem.snapshotTransitioning,
|
||||
let finalSize = transitionItem.targetFrame?.size else {
|
||||
return
|
||||
}
|
||||
|
||||
if snapshot.frame.size == .zero {
|
||||
snapshot.frame.size = initialSize
|
||||
}
|
||||
|
||||
let currentSize = snapshot.frame.size
|
||||
|
||||
let itemPercentComplete = clip(-0.05, 1.05, (currentSize.width - initialSize.width) / (finalSize.width - initialSize.width) + progress)
|
||||
let itemWidth = lerp(initialSize.width, finalSize.width, itemPercentComplete)
|
||||
let itemHeight = lerp(initialSize.height, finalSize.height, itemPercentComplete)
|
||||
assert(currentSize.width != 0.0)
|
||||
assert(currentSize.height != 0.0)
|
||||
let scaleTransform = CGAffineTransform(scaleX: (itemWidth / currentSize.width), y: (itemHeight / currentSize.height))
|
||||
let scaledOffset = transitionItem.touchOffset.apply(transform: scaleTransform)
|
||||
|
||||
snapshot.center = (snapshot.center + (translation + (transitionItem.touchOffset - scaledOffset))).point
|
||||
snapshot.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: itemWidth, height: itemHeight))
|
||||
transitionItem.touchOffset = scaledOffset
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// MediaPreviewTransitionController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class MediaPreviewTransitionController: NSObject {
|
||||
|
||||
weak var mediaPreviewViewController: MediaPreviewViewController?
|
||||
|
||||
var wantsInteractiveStart = false
|
||||
private var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let gestureRecognizer = UIPanGestureRecognizer()
|
||||
gestureRecognizer.maximumNumberOfTouches = 1
|
||||
return gestureRecognizer
|
||||
}()
|
||||
private var dismissInteractiveTransitioning: MediaHostToMediaPreviewViewControllerAnimatedTransitioning?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
panGestureRecognizer.delegate = self
|
||||
panGestureRecognizer.addTarget(self, action: #selector(MediaPreviewTransitionController.panGestureRecognizerHandler(_:)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewTransitionController {
|
||||
|
||||
@objc private func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) {
|
||||
guard dismissInteractiveTransitioning == nil else { return }
|
||||
|
||||
guard let mediaPreviewViewController = self.mediaPreviewViewController else { return }
|
||||
wantsInteractiveStart = true
|
||||
mediaPreviewViewController.dismiss(animated: true, completion: nil)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: start interactive dismiss", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
extension MediaPreviewTransitionController: UIGestureRecognizerDelegate {
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer {
|
||||
// FIXME: should enable zoom up pan dismiss
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
guard let mediaPreviewViewController = self.mediaPreviewViewController else { return false }
|
||||
return mediaPreviewViewController.isInteractiveDismissable()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerTransitioningDelegate
|
||||
extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegate {
|
||||
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
guard let mediaPreviewViewController = presented as? MediaPreviewViewController else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
self.mediaPreviewViewController = mediaPreviewViewController
|
||||
self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer)
|
||||
|
||||
return MediaHostToMediaPreviewViewControllerAnimatedTransitioning(
|
||||
operation: .push,
|
||||
transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem,
|
||||
panGestureRecognizer: panGestureRecognizer
|
||||
)
|
||||
}
|
||||
|
||||
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
||||
// not support interactive present
|
||||
return nil
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
guard let mediaPreviewViewController = dismissed as? MediaPreviewViewController else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
return MediaHostToMediaPreviewViewControllerAnimatedTransitioning(
|
||||
operation: .pop,
|
||||
transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem,
|
||||
panGestureRecognizer: panGestureRecognizer
|
||||
)
|
||||
}
|
||||
|
||||
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
||||
guard let transitioning = animator as? MediaHostToMediaPreviewViewControllerAnimatedTransitioning,
|
||||
transitioning.operation == .pop, wantsInteractiveStart else {
|
||||
return nil
|
||||
}
|
||||
|
||||
dismissInteractiveTransitioning = transitioning
|
||||
transitioning.delegate = self
|
||||
return transitioning
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ViewControllerAnimatedTransitioningDelegate
|
||||
extension MediaPreviewTransitionController: ViewControllerAnimatedTransitioningDelegate {
|
||||
|
||||
func animationEnded(_ transitionCompleted: Bool) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: completed: %s", ((#file as NSString).lastPathComponent), #line, #function, transitionCompleted.description)
|
||||
|
||||
dismissInteractiveTransitioning = nil
|
||||
wantsInteractiveStart = false
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// MediaPreviewTransitionItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
class MediaPreviewTransitionItem: Identifiable {
|
||||
|
||||
let id: UUID
|
||||
let source: Source
|
||||
var previewableViewController: MediaPreviewableViewController
|
||||
|
||||
// source
|
||||
// value maybe invalid when preview paging
|
||||
var image: UIImage?
|
||||
var aspectRatio: CGSize?
|
||||
var initialFrame: CGRect? = nil
|
||||
var sourceImageView: UIImageView?
|
||||
var sourceImageViewCornerRadius: CGFloat?
|
||||
|
||||
// target
|
||||
var targetFrame: CGRect? = nil
|
||||
|
||||
// transitioning
|
||||
var imageView: UIImageView?
|
||||
var snapshotRaw: UIView?
|
||||
var snapshotTransitioning: UIView?
|
||||
var touchOffset: CGVector = CGVector.zero
|
||||
|
||||
init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) {
|
||||
self.id = id
|
||||
self.source = source
|
||||
self.previewableViewController = previewableViewController
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MediaPreviewTransitionItem {
|
||||
enum Source {
|
||||
case mosaic(MosaicImageViewContainer)
|
||||
case profileAvatar(ProfileHeaderView)
|
||||
case profileBanner(ProfileHeaderView)
|
||||
|
||||
func updateAppearance(position: UIViewAnimatingPosition, index: Int?) {
|
||||
let alpha: CGFloat = position == .end ? 1 : 0
|
||||
switch self {
|
||||
case .mosaic(let mosaicImageViewContainer):
|
||||
if let index = index {
|
||||
mosaicImageViewContainer.setImageView(alpha: 0, index: index)
|
||||
} else {
|
||||
mosaicImageViewContainer.setImageViews(alpha: alpha)
|
||||
}
|
||||
case .profileAvatar(let profileHeaderView):
|
||||
profileHeaderView.avatarImageView.alpha = alpha
|
||||
case .profileBanner:
|
||||
break // keep source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// MediaPreviewableViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol MediaPreviewableViewController: AnyObject {
|
||||
var mediaPreviewTransitionController: MediaPreviewTransitionController { get }
|
||||
func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect?
|
||||
}
|
||||
|
||||
extension MediaPreviewableViewController {
|
||||
func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? {
|
||||
switch transitionItem.source {
|
||||
case .mosaic(let mosaicImageViewContainer):
|
||||
guard index < mosaicImageViewContainer.imageViews.count else { return nil }
|
||||
let imageView = mosaicImageViewContainer.imageViews[index]
|
||||
return imageView.superview!.convert(imageView.frame, to: nil)
|
||||
case .profileAvatar(let profileHeaderView):
|
||||
return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil)
|
||||
case .profileBanner:
|
||||
return nil // fallback to snapshot.frame
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// MediaPreviewingViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol MediaPreviewingViewController: AnyObject {
|
||||
func isInteractiveDismissable() -> Bool
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// ViewControllerAnimatedTransitioning.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
protocol ViewControllerAnimatedTransitioningDelegate: AnyObject {
|
||||
var wantsInteractiveStart: Bool { get }
|
||||
func animationEnded(_ transitionCompleted: Bool)
|
||||
}
|
||||
|
||||
class ViewControllerAnimatedTransitioning: NSObject {
|
||||
|
||||
let operation: UINavigationController.Operation
|
||||
|
||||
var transitionContext: UIViewControllerContextTransitioning!
|
||||
var isInteractive: Bool { return transitionContext.isInteractive }
|
||||
|
||||
weak var delegate: ViewControllerAnimatedTransitioningDelegate?
|
||||
|
||||
init(operation: UINavigationController.Operation) {
|
||||
assert(operation != .none)
|
||||
self.operation = operation
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerAnimatedTransitioning
|
||||
extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
self.transitionContext = transitionContext
|
||||
}
|
||||
|
||||
func animationEnded(_ transitionCompleted: Bool) {
|
||||
delegate?.animationEnded(transitionCompleted)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerInteractiveTransitioning
|
||||
extension ViewControllerAnimatedTransitioning: UIViewControllerInteractiveTransitioning {
|
||||
|
||||
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
|
||||
self.transitionContext = transitionContext
|
||||
}
|
||||
|
||||
var wantsInteractiveStart: Bool {
|
||||
return delegate?.wantsInteractiveStart ?? false
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
//
|
||||
// PhotoLibraryService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-29.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Photos
|
||||
import AlamofireImage
|
||||
|
||||
final class PhotoLibraryService: NSObject {
|
||||
|
||||
}
|
||||
|
||||
extension PhotoLibraryService {
|
||||
|
||||
enum PhotoLibraryError: Error {
|
||||
case noPermission
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PhotoLibraryService {
|
||||
|
||||
func saveImage(url: URL) -> AnyPublisher<UIImage, Error> {
|
||||
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||
|
||||
return Future<UIImage, Error> { promise in
|
||||
guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .denied else {
|
||||
promise(.failure(PhotoLibraryError.noPermission))
|
||||
return
|
||||
}
|
||||
|
||||
ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
switch response.result {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
case .success(let image):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription)
|
||||
self.save(image: image)
|
||||
promise(.success(image))
|
||||
}
|
||||
})
|
||||
}
|
||||
.handleEvents(receiveSubscription: { _ in
|
||||
impactFeedbackGenerator.impactOccurred()
|
||||
}, receiveCompletion: { completion in
|
||||
switch completion {
|
||||
case .failure:
|
||||
notificationFeedbackGenerator.notificationOccurred(.error)
|
||||
case .finished:
|
||||
notificationFeedbackGenerator.notificationOccurred(.success)
|
||||
}
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func save(image: UIImage, withNotificationFeedback: Bool = false) {
|
||||
UIImageWriteToSavedPhotosAlbum(
|
||||
image,
|
||||
self,
|
||||
#selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)),
|
||||
nil
|
||||
)
|
||||
|
||||
// assert no error
|
||||
if withNotificationFeedback {
|
||||
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||
notificationFeedbackGenerator.notificationOccurred(.success)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
|
||||
// TODO: notify banner
|
||||
}
|
||||
|
||||
}
|
|
@ -171,3 +171,19 @@ final class SettingService {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension SettingService {
|
||||
|
||||
static func openSettingsAlertController(title: String, message: String) -> UIAlertController {
|
||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
let settingAction = UIAlertAction(title: L10n.Common.Controls.Actions.settings, style: .default) { _ in
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
alertController.addAction(settingAction)
|
||||
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
|
||||
alertController.addAction(cancelAction)
|
||||
return alertController
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -30,7 +30,8 @@ class AppContext: ObservableObject {
|
|||
let statusPublishService = StatusPublishService()
|
||||
let notificationService: NotificationService
|
||||
let settingService: SettingService
|
||||
|
||||
let photoLibraryService = PhotoLibraryService()
|
||||
|
||||
let documentStore: DocumentStore
|
||||
private var documentStoreSubscription: AnyCancellable!
|
||||
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// CustomScheduler.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Ref: https://stackoverflow.com/a/59069315/3797903
|
||||
struct CustomScheduler: Scheduler {
|
||||
var runLoop: RunLoop
|
||||
var modes: [RunLoop.Mode] = [.default]
|
||||
|
||||
func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride,
|
||||
tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?,
|
||||
_ action: @escaping () -> Void) -> Cancellable {
|
||||
let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in
|
||||
action()
|
||||
}
|
||||
for mode in modes {
|
||||
runLoop.add(timer, forMode: mode)
|
||||
}
|
||||
return AnyCancellable {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride,
|
||||
options: Never?, _ action: @escaping () -> Void) {
|
||||
let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in
|
||||
timer.invalidate()
|
||||
action()
|
||||
}
|
||||
for mode in modes {
|
||||
runLoop.add(timer, forMode: mode)
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(options: Never?, _ action: @escaping () -> Void) {
|
||||
runLoop.perform(inModes: modes, block: action)
|
||||
}
|
||||
|
||||
var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) }
|
||||
var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) }
|
||||
|
||||
typealias SchedulerTimeType = RunLoop.SchedulerTimeType
|
||||
typealias SchedulerOptions = Never
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
Copyright (C) 2016 Apple Inc. All Rights Reserved.
|
||||
See LICENSE.txt for this sample’s licensing information
|
||||
|
||||
Abstract:
|
||||
Convenience math operators
|
||||
*/
|
||||
|
||||
import QuartzCore
|
||||
|
||||
func clip<T : Comparable>(_ x0: T, _ x1: T, _ v: T) -> T {
|
||||
return max(x0, min(x1, v))
|
||||
}
|
||||
|
||||
func lerp<T : FloatingPoint>(_ v0: T, _ v1: T, _ t: T) -> T {
|
||||
return v0 + (v1 - v0) * t
|
||||
}
|
||||
|
||||
|
||||
func -(lhs: CGPoint, rhs: CGPoint) -> CGVector {
|
||||
return CGVector(dx: lhs.x - rhs.x, dy: lhs.y - rhs.y)
|
||||
}
|
||||
|
||||
func -(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy)
|
||||
}
|
||||
|
||||
func -(lhs: CGVector, rhs: CGVector) -> CGVector {
|
||||
return CGVector(dx: lhs.dx - rhs.dx, dy: lhs.dy - rhs.dy)
|
||||
}
|
||||
|
||||
func +(lhs: CGPoint, rhs: CGPoint) -> CGVector {
|
||||
return CGVector(dx: lhs.x + rhs.x, dy: lhs.y + rhs.y)
|
||||
}
|
||||
|
||||
func +(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy)
|
||||
}
|
||||
|
||||
func +(lhs: CGVector, rhs: CGVector) -> CGVector {
|
||||
return CGVector(dx: lhs.dx + rhs.dx, dy: lhs.dy + rhs.dy)
|
||||
}
|
||||
|
||||
func *(left: CGVector, right:CGFloat) -> CGVector {
|
||||
return CGVector(dx: left.dx * right, dy: left.dy * right)
|
||||
}
|
||||
|
||||
extension CGPoint {
|
||||
var vector: CGVector {
|
||||
return CGVector(dx: x, dy: y)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGVector {
|
||||
var magnitude: CGFloat {
|
||||
return sqrt(dx*dx + dy*dy)
|
||||
}
|
||||
|
||||
var point: CGPoint {
|
||||
return CGPoint(x: dx, y: dy)
|
||||
}
|
||||
|
||||
func apply(transform t: CGAffineTransform) -> CGVector {
|
||||
return point.applying(t).vector
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue