feat: [WIP] add search result list for scopes searching

This commit is contained in:
CMK 2021-07-14 20:28:41 +08:00
parent 58c720c7df
commit 8d4752d71f
36 changed files with 1420 additions and 735 deletions

View File

@ -431,7 +431,8 @@
}
},
"search": {
"searchBar": {
"title": "Search",
"search_bar": {
"placeholder": "Search hashtags and users",
"cancel": "Cancel"
},
@ -452,7 +453,8 @@
"segment": {
"all": "All",
"people": "People",
"hashtags": "Hashtags"
"hashtags": "Hashtags",
"posts": "Posts"
},
"recent_search": "Recent searches",
"clear": "Clear"
@ -518,13 +520,13 @@
"using_default_browser": "Using default browser open link"
},
"boringzone": {
"title": "The Boring zone",
"title": "The Boring Zone",
"account_settings": "Account settings",
"terms": "Terms of Service",
"privacy": "Privacy Policy"
},
"spicyzone": {
"title": "The spicy zone",
"title": "The Spicy Zone",
"clear": "Clear Media Cache",
"signout": "Sign Out"
}

View File

@ -38,7 +38,6 @@
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
@ -141,7 +140,6 @@
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; };
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; };
@ -272,6 +270,13 @@
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; };
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; };
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; };
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; };
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; };
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */; };
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */; };
DB4FFC2C269EC39600D62E92 /* SearchDetailTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */; };
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; };
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; };
@ -512,6 +517,9 @@
DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; };
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */; };
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */; };
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */; };
DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DBF7A0FB26830C33004176A2 /* FPSIndicator */; };
DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; };
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -653,7 +661,6 @@
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
@ -753,7 +760,6 @@
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -911,6 +917,13 @@
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = "<group>"; };
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = "<group>"; };
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = "<group>"; };
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; };
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; };
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = "<group>"; };
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchToSearchDetailViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchDetailTransitionController.swift; sourceTree = "<group>"; };
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
@ -1137,6 +1150,9 @@
DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = "<group>"; };
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; };
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; };
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = "<group>"; };
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = "<group>"; };
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = "<group>"; };
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -1988,6 +2004,27 @@
path = EmojiService;
sourceTree = "<group>";
};
DB4F0964269ED06700D62E92 /* SearchResult */ = {
isa = PBXGroup;
children = (
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */,
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */,
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */,
);
path = SearchResult;
sourceTree = "<group>";
};
DB4FFC2D269EC39C00D62E92 /* SearchDetail */ = {
isa = PBXGroup;
children = (
DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */,
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */,
);
path = SearchDetail;
sourceTree = "<group>";
};
DB5086CB25CC0DB400C2C187 /* Preference */ = {
isa = PBXGroup;
children = (
@ -2041,6 +2078,7 @@
children = (
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
DB6180E726391B580018D199 /* MediaPreview */,
DB4FFC2D269EC39C00D62E92 /* SearchDetail */,
);
path = Transition;
sourceTree = "<group>";
@ -2385,15 +2423,10 @@
DB9D6BEE25E4F5370051B173 /* Search */ = {
isa = PBXGroup;
children = (
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
2DE0FAC62615F5D200CDF649 /* View */,
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */,
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */,
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
DBF1D253269DB02C00C1C08A /* Search */,
DBF1D24F269DAF6100C1C08A /* SearchDetail */,
DB4F0964269ED06700D62E92 /* SearchResult */,
DBF1D252269DB01700C1C08A /* SearchHistory */,
);
path = Search;
sourceTree = "<group>";
@ -2657,6 +2690,37 @@
path = Favorite;
sourceTree = "<group>";
};
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
isa = PBXGroup;
children = (
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */,
);
path = SearchDetail;
sourceTree = "<group>";
};
DBF1D252269DB01700C1C08A /* SearchHistory */ = {
isa = PBXGroup;
children = (
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
);
path = SearchHistory;
sourceTree = "<group>";
};
DBF1D253269DB02C00C1C08A /* Search */ = {
isa = PBXGroup;
children = (
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
2DE0FAC62615F5D200CDF649 /* View */,
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
);
path = Search;
sourceTree = "<group>";
};
DBF8AE14263293E400C9C23C /* NotificationService */ = {
isa = PBXGroup;
children = (
@ -3188,6 +3252,7 @@
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */,
@ -3216,6 +3281,7 @@
DBD376AA2692EA4F007FEC24 /* Theme.swift in Sources */,
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */,
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */,
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
@ -3250,7 +3316,7 @@
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */,
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */,
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */,
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
@ -3360,6 +3426,7 @@
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */,
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */,
@ -3411,11 +3478,13 @@
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */,
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
@ -3435,6 +3504,7 @@
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */,
DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */,
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
@ -3445,6 +3515,7 @@
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
DB4FFC2C269EC39600D62E92 /* SearchDetailTransitionController.swift in Sources */,
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
@ -3525,11 +3596,11 @@
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */,
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */,
DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
@ -3573,6 +3644,7 @@
DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */,
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */,
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */,
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,

View File

@ -52,7 +52,10 @@ extension SceneCoordinator {
// ASDK
case asyncHome
#endif
// search
case searchDetail(viewModel: SearchDetailViewModel)
// compose
case compose(viewModel: ComposeViewModel)
@ -254,6 +257,10 @@ private extension SceneCoordinator {
let _viewController = AsyncHomeTimelineViewController()
viewController = _viewController
#endif
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .compose(let viewModel):
let _viewController = ComposeViewController()
_viewController.viewModel = viewModel

View File

@ -11,12 +11,11 @@ import MastodonSDK
enum SearchResultItem {
case hashtag(tag: Mastodon.Entity.Tag)
case account(account: Mastodon.Entity.Account)
case accountObjectID(accountObjectID: NSManagedObjectID)
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case bottomLoader
}
@ -28,12 +27,14 @@ extension SearchResultItem: Equatable {
return tagLeft == tagRight
case (.account(let accountLeft), .account(let accountRight)):
return accountLeft == accountRight
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
return idLeft == idRight
case (.hashtagObjectID(let idLeft), .hashtagObjectID(let idRight)):
return idLeft == idRight
case (.status(let idLeft, _), .status(let idRight, _)):
return idLeft == idRight
case (.bottomLoader, .bottomLoader):
return true
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
return idLeft == idRight
case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)):
return idLeft == idRight
default:
return false
}
@ -44,15 +45,29 @@ extension SearchResultItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .account(let account):
hasher.combine(account)
hasher.combine(String(describing: SearchResultItem.account.self))
hasher.combine(account.id)
case .hashtag(let tag):
hasher.combine(tag)
hasher.combine(String(describing: SearchResultItem.hashtag.self))
hasher.combine(tag.name)
case .accountObjectID(let id):
hasher.combine(id)
case .hashtagObjectID(let id):
hasher.combine(id)
case .status(let id, _):
hasher.combine(id)
case .bottomLoader:
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
}
}
}
extension SearchResultItem {
var sortKey: String? {
switch self {
case .account(let account): return account.displayName.lowercased()
case .hashtag(let hashtag): return hashtag.name.lowercased()
default: return nil
}
}
}

View File

@ -12,42 +12,62 @@ import CoreData
import CoreDataStack
enum SearchResultSection: Equatable, Hashable {
case account
case hashtag
case mixed
case bottomLoader
case main
}
extension SearchResultSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
switch result {
UITableViewDiffableDataSource(tableView: tableView) { [
weak statusTableViewCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
switch item {
case .account(let account):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
cell.config(with: account)
return cell
case .hashtag(let tag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
cell.config(with: tag)
return cell
case .hashtagObjectID(let hashtagObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
cell.config(with: tag)
return cell
case .accountObjectID(let accountObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
cell.config(with: user)
// case .hashtagObjectID(let hashtagObjectID):
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
// let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
// cell.config(with: tag)
// return cell
// case .accountObjectID(let accountObjectID):
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
// let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
// cell.config(with: user)
// return cell
case .status(let statusObjectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
StatusSection.configure(
cell: cell,
tableView: tableView,
timelineContext: .search,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
}
cell.delegate = statusTableViewCellDelegate
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
}
}
}
default:
fatalError()
} // end switch
} // end UITableViewDiffableDataSource
} // end func
}

View File

@ -228,6 +228,7 @@ extension StatusSection {
case favorite
case hashtag
case report
case search
var filterContext: Mastodon.Entity.Filter.Context? {
switch self {

View File

@ -788,6 +788,8 @@ internal enum L10n {
}
}
internal enum Search {
/// Search
internal static let title = L10n.tr("Localizable", "Scene.Search.Title")
internal enum Recommend {
/// See All
internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText")
@ -810,11 +812,11 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title")
}
}
internal enum Searchbar {
internal enum SearchBar {
/// Cancel
internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel")
internal static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel")
/// Search hashtags and users
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder")
}
internal enum Searching {
/// Clear
@ -828,6 +830,8 @@ internal enum L10n {
internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags")
/// People
internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People")
/// Posts
internal static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts")
}
}
}
@ -948,7 +952,7 @@ internal enum L10n {
internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy")
/// Terms of Service
internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms")
/// The Boring zone
/// The Boring Zone
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title")
}
internal enum Notifications {
@ -986,7 +990,7 @@ internal enum L10n {
internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear")
/// Sign Out
internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout")
/// The spicy zone
/// The Spicy Zone
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title")
}
}

View File

@ -274,13 +274,15 @@ tap the link to confirm your account.";
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
"Scene.Search.Searchbar.Cancel" = "Cancel";
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
"Scene.Search.SearchBar.Cancel" = "Cancel";
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear";
"Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
"Scene.Search.Searching.Segment.People" = "People";
"Scene.Search.Searching.Segment.Posts" = "Posts";
"Scene.Search.Title" = "Search";
"Scene.ServerPicker.Button.Category.Academia" = "academia";
"Scene.ServerPicker.Button.Category.Activism" = "activism";
"Scene.ServerPicker.Button.Category.All" = "All";
@ -323,7 +325,7 @@ any server.";
"Scene.Settings.Section.Boringzone.AccountSettings" = "Account settings";
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
"Scene.Settings.Section.Boringzone.Title" = "The Boring Zone";
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
@ -338,7 +340,7 @@ any server.";
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
"Scene.Settings.Section.Spicyzone.Title" = "The Spicy Zone";
"Scene.Settings.Title" = "Settings";
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, youll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";

View File

@ -274,13 +274,15 @@ tap the link to confirm your account.";
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
"Scene.Search.Searchbar.Cancel" = "Cancel";
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
"Scene.Search.SearchBar.Cancel" = "Cancel";
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear";
"Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
"Scene.Search.Searching.Segment.People" = "People";
"Scene.Search.Searching.Segment.Posts" = "Posts";
"Scene.Search.Title" = "Search";
"Scene.ServerPicker.Button.Category.Academia" = "academia";
"Scene.ServerPicker.Button.Category.Activism" = "activism";
"Scene.ServerPicker.Button.Category.All" = "All";
@ -323,7 +325,7 @@ any server.";
"Scene.Settings.Section.Boringzone.AccountSettings" = "Account settings";
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
"Scene.Settings.Section.Boringzone.Title" = "The Boring Zone";
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
@ -338,7 +340,7 @@ any server.";
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
"Scene.Settings.Section.Spicyzone.Title" = "The Spicy Zone";
"Scene.Settings.Title" = "Settings";
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, youll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";

View File

@ -242,8 +242,6 @@ extension ComposeViewController {
}
return margin
}()
// update keyboard background color
guard isShow, state == .dock else {
self.tableView.contentInset.bottom = extraMargin
@ -591,19 +589,6 @@ extension ComposeViewController {
private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
// Deprecated: not works for new Dark Mode color
// guard isKeyboardDisplay else {
// composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
// return
// }
// composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in
// // avoid elevated color
// switch traitCollection.userInterfaceStyle {
// case .light: return .white
// default: return .black
// }
// })
}
private func setupBackgroundColor(theme: Theme) {

View File

@ -101,14 +101,13 @@ extension MainTabBarController {
delegate = self
view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
// ThemeService.shared.currentTheme
// .receive(on: RunLoop.main)
// .sink { [weak self] theme in
// guard let self = self else { return }
// // fix tab bar not update color issue
// self.tabBar.backgroundColor = theme.tabBarBackgroundColor
// }
// .store(in: &disposeBag)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.tabBarBackgroundColor
}
.store(in: &disposeBag)
let tabs = Tab.allCases
let viewControllers: [UIViewController] = tabs.map { tab in
@ -189,6 +188,10 @@ extension MainTabBarController {
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
}
.store(in: &disposeBag)
#if DEBUG
// selectedIndex = 1
#endif
}
}

View File

@ -62,12 +62,19 @@ extension SearchViewController: UICollectionViewDelegate {
case self.accountsCollectionView:
guard let diffableDataSource = viewModel.accountDiffableDataSource else { return }
guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
let user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self)
let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
}
case self.hashtagCollectionView:
guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return }
guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self)
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
DispatchQueue.main.async {
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
}
default:
break
}

View File

@ -0,0 +1,198 @@
//
// SearchViewController.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import os.log
import Combine
import GameplayKit
import MastodonSDK
import UIKit
final class SearchViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: "Search", category: "UI")
public static var hashtagCardHeight: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 186
}
return 130
}
}
public static var hashtagPeopleTalkingLabelTop: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 18
}
return 6
}
}
public static let accountCardHeight = 202
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var searchDetailTransitionController = SearchDetailTransitionController()
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
// recommend
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
scrollView.clipsToBounds = false
return scrollView
}()
let stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fill
return stackView
}()
let hashtagCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let accountsCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
}
extension SearchViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
title = L10n.Scene.Search.title
setupSearchBar()
setupScrollView()
setupHashTagCollectionView()
setupAccountsCollectionView()
setupDataSource()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppeared.send()
}
}
extension SearchViewController {
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemGroupedBackgroundColor
}
private func setupSearchBar() {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
searchBar.delegate = self
navigationItem.titleView = searchBar
}
private func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
// scrollView
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
])
// stack view
scrollView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
])
}
private func setupDataSource() {
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
}
}
// MARK: - UISearchBarDelegate
extension SearchViewController: UISearchBarDelegate {
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// push to search detail
let searchDetailViewModel = SearchDetailViewModel()
searchDetailViewModel.needsBecomeFirstResponder = true
self.navigationController?.delegate = self.searchDetailTransitionController
self.coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .customPush)
return false
}
}
// MARK - UISearchControllerDelegate
extension SearchViewController: UISearchControllerDelegate {
func willDismissSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
searchController.isActive = true
}
func didPresentSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct SearchViewController_Previews: PreviewProvider {
static var previews: some View {
UIViewControllerPreview {
let viewController = SearchViewController()
return viewController
}
.previewLayout(.fixed(width: 375, height: 800))
}
}
#endif

View File

@ -38,25 +38,8 @@ final class SearchViewModel: NSObject {
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>?
let statusFetchedResultsController: StatusFetchedResultsController
// bottom loader
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadOldestState.Initial(viewModel: self),
LoadOldestState.Loading(viewModel: self),
LoadOldestState.Fail(viewModel: self),
LoadOldestState.Idle(viewModel: self),
LoadOldestState.NoMore(viewModel: self),
])
stateMachine.enter(LoadOldestState.Initial.self)
return stateMachine
}()
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
init(context: AppContext, coordinator: SceneCoordinator) {
self.coordinator = coordinator
@ -134,38 +117,6 @@ final class SearchViewModel: NSObject {
}
}
.store(in: &disposeBag)
Publishers.CombineLatest3(
isSearching,
searchText,
searchScope
)
.filter { isSearching, _, _ in
isSearching
}
.sink { [weak self] _, text, scope in
guard text.isEmpty else { return }
guard let self = self else { return }
guard let searchHistories = self.fetchSearchHistory() else { return }
guard let dataSource = self.searchResultDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
snapshot.appendSections([.mixed])
searchHistories.forEach { searchHistory in
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default
if let mastodonUser = searchHistory.account, containsAccount {
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
snapshot.appendItems([item], toSection: .mixed)
}
if let tag = searchHistory.hashtag, containsHashTag {
let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID)
snapshot.appendItems([item], toSection: .mixed)
}
}
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
@ -232,32 +183,6 @@ final class SearchViewModel: NSObject {
}
}
.store(in: &disposeBag)
searchResult
.receive(on: DispatchQueue.main)
.sink { [weak self] searchResult in
guard let self = self else { return }
guard let dataSource = self.searchResultDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
if let accounts = searchResult?.accounts {
snapshot.appendSections([.account])
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
snapshot.appendItems(items, toSection: .account)
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .account)
}
}
if let tags = searchResult?.hashtags {
snapshot.appendSections([.hashtag])
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
snapshot.appendItems(items, toSection: .hashtag)
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
}
}
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
}
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
@ -295,21 +220,6 @@ final class SearchViewModel: NSObject {
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
}
}
func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) {
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
DispatchQueue.main.async {
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
}
}
func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) {
let searchHistories = fetchSearchHistory()

View File

@ -0,0 +1,231 @@
//
// SearchDetailViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import os.log
import UIKit
import Combine
import Pageboy
final class SearchDetailViewController: PageboyViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchDetail", category: "UI")
var disposeBag = Set<AnyCancellable>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: SearchDetailViewModel!
var viewControllers: [SearchResultViewController]!
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
return searchBar
}()
}
extension SearchDetailViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.setHidesBackButton(true, animated: false)
setupSearchBar()
transition = Transition(style: .fade, duration: 0.1)
// transition = nil
isScrollEnabled = false
viewControllers = viewModel.searchScopes.map { scope in
let searchResultViewController = SearchResultViewController()
searchResultViewController.context = context
searchResultViewController.coordinator = coordinator
searchResultViewController.viewModel = SearchResultViewModel(context: context, searchScope: scope)
// bind searchText
viewModel.searchText
.assign(to: \.value, on: searchResultViewController.viewModel.searchText)
.store(in: &searchResultViewController.disposeBag)
return searchResultViewController
}
// set initial items from "all" search scope for non-appeared lists
if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) {
allSearchScopeViewController.viewModel.items
.receive(on: DispatchQueue.main)
.sink { [weak self] items in
guard let self = self else { return }
guard self.currentViewController === allSearchScopeViewController else { return }
for viewController in self.viewControllers where viewController != allSearchScopeViewController {
// do not change appeared list
guard !viewController.viewModel.viewDidAppear.value else { continue }
// set initial items
switch viewController.viewModel.searchScope {
case .all:
assertionFailure()
break
case .people:
viewController.viewModel.items.value = items.filter { item in
guard case .account = item else { return false }
return true
}
case .hashtags:
viewController.viewModel.items.value = items.filter { item in
guard case .hashtag = item else { return false }
return true
}
case .posts:
viewController.viewModel.items.value = items.filter { item in
guard case .status = item else { return false }
return true
}
}
}
}
.store(in: &allSearchScopeViewController.disposeBag)
}
dataSource = self
delegate = self
// bind search bar scope
viewModel.selectedSearchScope
.receive(on: DispatchQueue.main)
.sink { [weak self] searchScope in
guard let self = self else { return }
if let index = self.viewModel.searchScopes.firstIndex(of: searchScope) {
self.searchBar.selectedScopeButtonIndex = index
self.scrollToPage(.at(index: index), animated: true)
}
}
.store(in: &disposeBag)
// bind search trigger
viewModel.searchText
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.removeDuplicates()
.sink { [weak self] searchText in
guard let self = self else { return }
guard let searchResultViewController = self.currentViewController as? SearchResultViewController else {
return
}
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): trigger search \(searchText)")
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
}
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
searchBar.setShowsCancelButton(true, animated: animated)
searchBar.becomeFirstResponder()
}
}
extension SearchDetailViewController {
private func setupSearchBar() {
navigationItem.titleView = searchBar
searchBar.setShowsScope(true, animated: false)
searchBar.sizeToFit()
searchBar.delegate = self
}
}
// MARK: - UISearchBarDelegate
extension SearchDetailViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope]
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): searchTest \(searchText)")
viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
navigationController?.popViewController(animated: true)
}
}
// MARK: - PageboyViewControllerDataSource
extension SearchDetailViewController: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return 4
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
guard index < viewControllers.count else { return nil }
return viewControllers[index]
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
return .first
}
}
// MARK: - PageboyViewControllerDelegate
extension SearchDetailViewController: 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,
didCancelScrollToPageAt index: PageboyViewController.PageIndex,
returnToPageAt previousIndex: PageboyViewController.PageIndex
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): index \(index)")
let searchResultViewController = viewControllers[index]
viewModel.selectedSearchScope.value = searchResultViewController.viewModel.searchScope
// trigger fetch
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didReloadWith currentViewController: UIViewController,
currentPageIndex: PageboyViewController.PageIndex
) {
// do nothing
}
}

View File

@ -0,0 +1,60 @@
//
// SearchDetailViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import os.log
import Foundation
import Combine
import MastodonSDK
final class SearchDetailViewModel {
// input
var needsBecomeFirstResponder = false
let viewDidAppear = PassthroughSubject<Void, Never>()
// output
let searchScopes = SearchScope.allCases
let selectedSearchScope = CurrentValueSubject<SearchScope, Never>(.all)
let searchText: CurrentValueSubject<String, Never>
let searchActionPublisher = PassthroughSubject<Void, Never>()
init(initialSearchText: String = "") {
self.searchText = CurrentValueSubject(initialSearchText)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension SearchDetailViewModel {
enum SearchScope: CaseIterable {
case all
case people
case hashtags
case posts
var segmentedControlTitle: String {
switch self {
case .all: return L10n.Scene.Search.Searching.Segment.all
case .people: return L10n.Scene.Search.Searching.Segment.people
case .hashtags: return L10n.Scene.Search.Searching.Segment.hashtags
case .posts: return L10n.Scene.Search.Searching.Segment.posts
}
}
var searchType: Mastodon.API.V2.Search.SearchType {
switch self {
case .all: return .default
case .people: return .accounts
case .hashtags: return .hashtags
case .posts: return .statuses
}
}
}
}

View File

@ -0,0 +1,29 @@
//
// SearchHistoryTableHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import Foundation
// lazy var searchHeader: UIView = {
// let view = UIView()
// view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
// return view
// }()
//
// let recentSearchesLabel: UILabel = {
// let label = UILabel()
// label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
// label.textColor = Asset.Colors.Label.primary.color
// label.text = L10n.Scene.Search.Searching.recentSearch
// return label
// }()
//
// let clearSearchHistoryButton: HighlightDimmableButton = {
// let button = HighlightDimmableButton(type: .custom)
// button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
// button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
// return button
// }()

View File

@ -0,0 +1,17 @@
//
// SearchHistoryViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import UIKit
final class SearchHistoryViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
}
extension SearchHistoryViewController {
}

View File

@ -0,0 +1,69 @@
//
// SearchResultViewController+StatusProvider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
// MARK: - StatusProvider
extension SearchResultViewController: StatusProvider {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
switch item {
case .status(let objectID, _):
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let status = managedObjectContext.object(with: objectID) as? Status
promise(.success(status))
}
default:
promise(.success(nil))
}
}
}
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return nil
}
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
return nil
}
func items(indexPaths: [IndexPath]) -> [Item] {
return []
}
}
extension SearchResultViewController: UserProvider {}

View File

@ -0,0 +1,139 @@
//
// SearchResultViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import UIKit
import Combine
import AVKit
final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
let mediaPreviewTransitionController = MediaPreviewTransitionController()
var viewModel: SearchResultViewModel!
var disposeBag = Set<AnyCancellable>()
let tableView: UITableView = {
let tableView = UITableView()
tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self))
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
return tableView
}()
}
extension SearchResultViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
viewModel.setupDiffableDataSource(
tableView: tableView,
dependency: self,
statusTableViewCellDelegate: self
)
// listen keyboard events and set content inset
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
Publishers.CombineLatest(
keyboardEventPublishers,
viewModel.viewDidAppear
)
.sink(receiveValue: { [weak self] keyboardEvents, _ in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
// update keyboard background color
guard isShow, state == .dock else {
self.tableView.contentInset.bottom = 0
self.tableView.verticalScrollIndicatorInsets.bottom = 0
return
}
// isShow AND dock state
// adjust inset for tableView
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
let padding = contentFrame.maxY - endFrame.minY
guard padding > 0 else {
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
return
}
self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom
self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom
})
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
}
}
extension SearchResultViewController {
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemGroupedBackgroundColor
tableView.backgroundColor = theme.systemBackgroundColor
// searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
}
}
// MARK: - AVPlayerViewControllerDelegate
extension SearchResultViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - StatusTableViewCellDelegate
extension SearchResultViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}
//extension SearchResultViewController: LoadMoreConfigurableTableViewContainer {
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
// typealias LoadingState = SearchViewModel.LoadOldestState.Loading
// var loadMoreConfigurableTableView: UITableView { searchingTableView }
// var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
//}

View File

@ -0,0 +1,189 @@
//
// SearchResultViewModel+State.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension SearchResultViewModel {
class State: GKState {
weak var viewModel: SearchResultViewModel?
init(viewModel: SearchResultViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
// viewModel?.loadOldestStateMachinePublisher.send(self)
}
}
}
extension SearchResultViewModel.State {
class Initial: SearchResultViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
return stateClass == Loading.self && !viewModel.searchText.value.isEmpty
}
}
class Loading: SearchResultViewModel.State {
let logger = Logger(subsystem: "SearchResultViewModel.State.Loading", category: "Logic")
var previousSearchText = ""
var offset = 0
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = self.viewModel else { return false }
switch stateClass {
case is Fail.Type, is Idle.Type, is NoMore.Type:
return true
case is Loading.Type:
return viewModel.searchText.value != previousSearchText
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
stateMachine.enter(Fail.self)
return
}
if previousState is Initial {
// trigger bottom loader display
viewModel.items.value = viewModel.items.value
}
let domain = activeMastodonAuthenticationBox.domain
let searchText = viewModel.searchText.value
let searchType = viewModel.searchScope.searchType
guard !searchText.isEmpty else {
stateMachine.enter(Fail.self)
return
}
if searchText != previousSearchText {
previousSearchText = searchText
}
// not set offset for all case
// and assert other cases the items are all the same type elements
let offset: Int? = {
switch searchType {
case .default: return nil
default:
return viewModel.items.value.isEmpty ? nil : viewModel.items.value.count
}
}()
let query = Mastodon.API.V2.Search.Query(
q: searchText,
type: searchType,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: offset,
following: nil
)
viewModel.context.apiService.search(
domain: domain,
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)")
stateMachine.enter(Fail.self)
case .finished:
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) success")
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
// discard result when search text is outdated
guard searchText == self.previousSearchText else { return }
guard stateMachine.currentState is Loading else { return }
let oldItems = offset == nil ? [] : viewModel.items.value
var newItems: [SearchResultItem] = []
for account in response.value.accounts {
let item = SearchResultItem.account(account: account)
guard !oldItems.contains(item) else { continue }
newItems.append(item)
}
for hashtag in response.value.hashtags {
let item = SearchResultItem.hashtag(tag: hashtag)
guard !oldItems.contains(item) else { continue }
newItems.append(item)
}
if searchType == .default {
newItems.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")})
}
var newStatusIDs = offset == nil ? [] : viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value.statuses {
guard !newStatusIDs.contains(status.id) else { continue }
newStatusIDs.append(status.id)
}
stateMachine.enter(Idle.self)
viewModel.items.value = oldItems + newItems
viewModel.statusFetchedResultsController.statusIDs.value = newStatusIDs
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: SearchResultViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
}
class Idle: SearchResultViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
}
class NoMore: SearchResultViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
}
}

View File

@ -0,0 +1,121 @@
//
// SearchResultViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import GameplayKit
final class SearchResultViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let searchScope: SearchDetailViewModel.SearchScope
let searchText = CurrentValueSubject<String, Never>("")
let statusFetchedResultsController: StatusFetchedResultsController
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
// output
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Loading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let items = CurrentValueSubject<[SearchResultItem], Never>([])
var diffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>!
init(context: AppContext, searchScope: SearchDetailViewModel.SearchScope) {
self.context = context
self.searchScope = searchScope
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
Publishers.CombineLatest(
items,
statusFetchedResultsController.objectIDs.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] items, statusObjectIDs in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
snapshot.appendSections([.main])
// append account & hashtag items
snapshot.appendItems(items, toSection: .main)
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
// append statuses
var statusItems: [SearchResultItem] = []
for objectID in statusObjectIDs {
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
statusItems.append(.status(statusObjectID: objectID, attribute: attribute))
}
snapshot.appendItems(statusItems, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Loading, is State.Fail, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore:
break
default:
break
}
}
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot, animatingDifferences: true)
}
.store(in: &disposeBag)
}
}
extension SearchResultViewModel {
func setupDiffableDataSource(
tableView: UITableView,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) {
diffableDataSource = SearchResultSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
statusTableViewCellDelegate: statusTableViewCellDelegate
)
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
snapshot.appendSections([.main])
snapshot.appendItems(self.items.value, toSection: .main) // with initial items
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
}

View File

@ -13,7 +13,7 @@ import UIKit
import FLAnimatedImage
import Nuke
final class SearchingTableViewCell: UITableViewCell {
final class SearchResultTableViewCell: UITableViewCell {
let _imageView: UIImageView = {
let imageView = FLAnimatedImageView()
@ -54,7 +54,7 @@ final class SearchingTableViewCell: UITableViewCell {
}
}
extension SearchingTableViewCell {
extension SearchResultTableViewCell {
private func configure() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
@ -74,8 +74,8 @@ extension SearchingTableViewCell {
_imageView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(_imageView)
NSLayoutConstraint.activate([
_imageView.widthAnchor.constraint(equalToConstant: 42),
_imageView.heightAnchor.constraint(equalToConstant: 42),
_imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
_imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
])
let textStackView = UIStackView()
@ -120,7 +120,7 @@ extension SearchingTableViewCell {
func config(with tag: Mastodon.Entity.Tag) {
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
_imageView.image = image
_titleLabel.text = "# " + tag.name
_titleLabel.text = "#" + tag.name
guard let histories = tag.history else {
_subTitleLabel.text = ""
return
@ -151,11 +151,11 @@ extension SearchingTableViewCell {
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct SearchingTableViewCell_Previews: PreviewProvider {
struct SearchResultTableViewCell_Previews: PreviewProvider {
static var controls: some View {
Group {
UIViewPreview {
let cell = SearchingTableViewCell()
let cell = SearchResultTableViewCell()
cell.backgroundColor = .white
cell._imageView.image = UIImage(systemName: "number.circle.fill")
cell._titleLabel.text = "Electronic Frontier Foundation"

View File

@ -1,93 +0,0 @@
//
// SearchViewController+Searching.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/2.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import OSLog
import UIKit
extension SearchViewController {
func setupSearchingTableView() {
searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
searchingTableView.estimatedRowHeight = 66
searchingTableView.rowHeight = 66
view.addSubview(searchingTableView)
searchingTableView.delegate = self
searchingTableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
searchingTableView.tableFooterView = UIView()
searchingTableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
viewModel.isSearching
.receive(on: DispatchQueue.main)
.sink { [weak self] isSearching in
self?.searchingTableView.isHidden = !isSearching
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.isSearching,
viewModel.searchText
)
.sink { [weak self] isSearching, text in
guard let self = self else { return }
if isSearching, text.isEmpty {
self.searchingTableView.tableHeaderView = self.searchHeader
} else {
self.searchingTableView.tableHeaderView = nil
}
}
.store(in: &disposeBag)
}
func setupSearchHeader() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
containerStackView.isLayoutMarginsRelativeArrangement = true
searchHeader.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor)
])
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(recentSearchesLabel)
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(clearSearchHistoryButton)
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
}
}
extension SearchViewController {
@objc func clearAction(_ sender: UIButton) {
viewModel.deleteSearchHistory()
}
}
// MARK: - UITableViewDelegate
extension SearchViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.searchResultItemDidSelected(item: item, from: self)
}
}

View File

@ -1,295 +0,0 @@
//
// SearchViewController.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Combine
import GameplayKit
import MastodonSDK
import UIKit
final class SearchViewController: UIViewController, NeedsDependency {
public static var hashtagCardHeight: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 186
}
return 130
}
}
public static var hashtagPeopleTalkingLabelTop: CGFloat {
get {
if UIScreen.main.bounds.size.height > 736 {
return 18
}
return 6
}
}
public static let accountCardHeight = 202
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
let statusBar: UIView = {
let view = UIView()
return view
}()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
searchBar.translatesAutoresizingMaskIntoConstraints = false
// let micImage = UIImage(systemName: "mic.fill")
// searchBar.setImage(micImage, for: .bookmark, state: .normal)
// searchBar.showsBookmarkButton = true
searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags]
return searchBar
}()
// recommend
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
scrollView.clipsToBounds = false
return scrollView
}()
let stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fill
return stackView
}()
let hashtagCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let accountsCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
// searching
let searchingTableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
tableView.separatorColor = UIView.separatorColor
return tableView
}()
lazy var searchHeader: UIView = {
let view = UIView()
view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
return view
}()
let recentSearchesLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.Search.Searching.recentSearch
return label
}()
let clearSearchHistoryButton: HighlightDimmableButton = {
let button = HighlightDimmableButton(type: .custom)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
return button
}()
}
extension SearchViewController {
override func viewDidLoad() {
super.viewDidLoad()
let barAppearance = UINavigationBarAppearance()
barAppearance.configureWithTransparentBackground()
navigationItem.standardAppearance = barAppearance
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
navigationItem.hidesBackButton = true
setupSearchBar()
setupScrollView()
setupHashTagCollectionView()
setupAccountsCollectionView()
setupSearchingTableView()
setupDataSource()
setupSearchHeader()
view.bringSubviewToFront(searchBar)
view.bringSubviewToFront(statusBar)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppeared.send()
}
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemGroupedBackgroundColor
searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
searchingTableView.backgroundColor = theme.systemBackgroundColor
statusBar.backgroundColor = theme.navigationBarBackgroundColor
}
func setupSearchBar() {
searchBar.delegate = self
view.addSubview(searchBar)
searchBar.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
statusBar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusBar)
NSLayoutConstraint.activate([
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
])
}
func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
// scrollView
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
])
// stackview
scrollView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
])
}
func setupDataSource() {
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
}
}
extension SearchViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == searchingTableView {
handleScrollViewDidScroll(scrollView)
}
}
}
extension SearchViewController: UISearchBarDelegate {
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(true, animated: true)
searchBar.showsScopeBar = true
viewModel.isSearching.value = true
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
searchBar.showsScopeBar = false
viewModel.isSearching.value = true
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
searchBar.showsScopeBar = false
searchBar.text = ""
searchBar.resignFirstResponder()
viewModel.isSearching.value = false
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.searchText.send(searchText)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
switch selectedScope {
case 0:
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
case 1:
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
case 2:
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
default:
break
}
}
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {}
}
extension SearchViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = SearchViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { searchingTableView }
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct SearchViewController_Previews: PreviewProvider {
static var previews: some View {
UIViewControllerPreview {
let viewController = SearchViewController()
return viewController
}
.previewLayout(.fixed(width: 375, height: 800))
}
}
#endif

View File

@ -1,146 +0,0 @@
//
// SearchViewModel+LoadOldestState.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/6.
//
import Foundation
import GameplayKit
import MastodonSDK
import os.log
extension SearchViewModel {
class LoadOldestState: GKState {
weak var viewModel: SearchViewModel?
init(viewModel: SearchViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
viewModel?.loadOldestStateMachinePublisher.send(self)
}
}
}
extension SearchViewModel.LoadOldestState {
class Initial: SearchViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
guard viewModel.searchResult.value != nil else { return false }
return stateClass == Loading.self
}
}
class Loading: SearchViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
stateMachine.enter(Fail.self)
return
}
guard let oldSearchResult = viewModel.searchResult.value else {
stateMachine.enter(Fail.self)
return
}
var offset = 0
switch viewModel.searchScope.value {
case Mastodon.API.V2.Search.SearchType.accounts:
offset = oldSearchResult.accounts.count
case Mastodon.API.V2.Search.SearchType.hashtags:
offset = oldSearchResult.hashtags.count
default:
return
}
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
type: viewModel.searchScope.value,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: offset,
following: nil)
viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { completion in
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { result in
switch viewModel.searchScope.value {
case Mastodon.API.V2.Search.SearchType.accounts:
if result.value.accounts.isEmpty {
stateMachine.enter(NoMore.self)
} else {
var newAccounts = [Mastodon.Entity.Account]()
newAccounts.append(contentsOf: oldSearchResult.accounts)
newAccounts.append(contentsOf: result.value.accounts)
newAccounts.removeDuplicates()
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
stateMachine.enter(Idle.self)
}
case Mastodon.API.V2.Search.SearchType.hashtags:
if result.value.hashtags.isEmpty {
stateMachine.enter(NoMore.self)
} else {
var newTags = [Mastodon.Entity.Tag]()
newTags.append(contentsOf: oldSearchResult.hashtags)
newTags.append(contentsOf: result.value.hashtags)
newTags.removeDuplicates()
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags)
stateMachine.enter(Idle.self)
}
default:
return
}
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: SearchViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: SearchViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self
}
}
class NoMore: SearchViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// reset state if needs
stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
guard let viewModel = viewModel else { return }
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else {
assertionFailure()
return
}
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}

View File

@ -7,7 +7,7 @@
import UIKit
// Make status bar style adptive for child view controller
// Make status bar style adaptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
var viewControllersHiddenNavigationBar: [UIViewController.Type]
@ -19,7 +19,7 @@ final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
override init(rootViewController: UIViewController) {
self.viewControllersHiddenNavigationBar = [SearchViewController.self]
super.init(rootViewController: rootViewController)
self.delegate = self
// self.delegate = self
}
@available(*, unavailable)
@ -28,13 +28,13 @@ final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
}
}
extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
if isContain {
self.setNavigationBarHidden(true, animated: animated)
} else {
self.setNavigationBarHidden(false, animated: animated)
}
}
}
//extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
// func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
// if isContain {
// self.setNavigationBarHidden(true, animated: animated)
// } else {
// self.setNavigationBarHidden(false, animated: animated)
// }
// }
//}

View File

@ -0,0 +1,29 @@
//
// SearchDetailTransitionController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import UIKit
final class SearchDetailTransitionController: NSObject {
}
// MARK: - UINavigationControllerDelegate
extension SearchDetailTransitionController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
switch operation {
case .push where fromVC is SearchViewController && toVC is SearchDetailViewController:
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
case .pop where fromVC is SearchDetailViewController && toVC is SearchViewController:
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
default:
// fix edge dismiss gesture
toVC.navigationController?.interactivePopGestureRecognizer?.delegate = nil
// assertionFailure("Wrong setup. Edge-drag gesture will be invalid. Set delegate to nil when using system push configuration")
return nil
}
}
}

View File

@ -0,0 +1,79 @@
//
// SearchToSearchDetailViewControllerAnimatedTransitioning.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import os.log
import UIKit
final class SearchToSearchDetailViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning {
private var animator: UIViewPropertyAnimator?
override init(operation: UINavigationController.Operation) {
super.init(operation: operation)
self.transitionDuration = 0.01
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - UIViewControllerAnimatedTransitioning
extension SearchToSearchDetailViewControllerAnimatedTransitioning {
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? SearchDetailViewController,
let toView = transitionContext.view(forKey: .to) else {
fatalError()
}
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
transitionContext.containerView.addSubview(toView)
toView.frame = toViewEndFrame
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
animator.addAnimations {
}
animator.addCompletion { position in
transitionContext.completeTransition(true)
}
return animator
}
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchViewController,
let toView = transitionContext.view(forKey: .to) else {
fatalError()
}
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
transitionContext.containerView.addSubview(toView)
toView.frame = toViewEndFrame
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
animator.addAnimations {
}
animator.addCompletion { position in
transitionContext.completeTransition(true)
}
return animator
}
}

View File

@ -17,6 +17,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
let operation: UINavigationController.Operation
var transitionDuration: TimeInterval
var transitionContext: UIViewControllerContextTransitioning!
var isInteractive: Bool { return transitionContext.isInteractive }
@ -25,6 +26,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
init(operation: UINavigationController.Operation) {
assert(operation != .none)
self.operation = operation
self.transitionDuration = 0.3
super.init()
}
@ -38,7 +40,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
return transitionDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

View File

@ -6,8 +6,9 @@
//
import Foundation
import MastodonSDK
import Combine
import MastodonSDK
import CommonOSLog
extension APIService {
@ -17,7 +18,32 @@ extension APIService {
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
// persist status
let statusResponse = response.map { $0.statuses }
return APIService.Persist.persistStatus(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: nil,
response: statusResponse,
persistType: .lookUp,
requestMastodonUserID: requestMastodonUserID,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.SearchResult> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -105,8 +105,8 @@ extension Mastodon.API.V2.Search {
}
}
public extension Mastodon.API.V2.Search {
enum SearchType: String, Codable {
extension Mastodon.API.V2.Search {
public enum SearchType: String, Codable {
case accounts
case hashtags
case statuses