Merge pull request #443 from mastodon/fix-issue-440

Fix profile layout may crash issue #440
This commit is contained in:
CMK 2022-05-26 23:46:39 +08:00 committed by GitHub
commit cd32cedfc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1533 additions and 1575 deletions

View File

@ -27,6 +27,7 @@
- [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) - [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
- [Tabman](https://github.com/uias/Tabman) - [Tabman](https://github.com/uias/Tabman)
- [TabBarPager](https://github.com/TwidereProject/TabBarPager)
- [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS) - [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS)
- [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer) - [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer)
- [TOCropViewController](https://github.com/TimOliver/TOCropViewController) - [TOCropViewController](https://github.com/TimOliver/TOCropViewController)

View File

@ -145,6 +145,8 @@
DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; }; DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; };
DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; }; DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; };
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; };
DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */; };
DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */; };
DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; }; DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; };
DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; }; DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; };
DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; }; DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; };
@ -267,6 +269,7 @@
DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */; }; DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */; };
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; };
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; };
DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */ = {isa = PBXBuildFile; productRef = DB486C0E282E41F200F69423 /* TabBarPager */; };
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; };
DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; }; DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; };
DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; };
@ -507,7 +510,6 @@
DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; }; DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; };
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; }; DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; };
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; };
@ -888,6 +890,8 @@
DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = "<group>"; }; DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = "<group>"; };
DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = "<group>"; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+ViewModel.swift"; sourceTree = "<group>"; };
DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+Configuration.swift"; sourceTree = "<group>"; };
DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = "<group>"; }; DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = "<group>"; };
DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = "<group>"; }; DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = "<group>"; };
DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = "<group>"; }; DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = "<group>"; };
@ -1285,7 +1289,6 @@
DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; }; DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; };
DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = "<group>"; }; DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = "<group>"; };
DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = "<group>"; }; DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = "<group>"; }; DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = "<group>"; };
@ -1429,6 +1432,7 @@
DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */,
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */,
DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */,
DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */,
DB552D4F26BBD10C00E481F6 /* OrderedCollections in Frameworks */, DB552D4F26BBD10C00E481F6 /* OrderedCollections in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */, DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */,
@ -3004,8 +3008,8 @@
DB9D6C0825E4F5A60051B173 /* Profile */ = { DB9D6C0825E4F5A60051B173 /* Profile */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DBB525132611EBB1002F1F29 /* Segmented */,
DBB525462611ED57002F1F29 /* Header */, DBB525462611ED57002F1F29 /* Header */,
DBB525262611EBDA002F1F29 /* Paging */,
DBB5253B2611ECF5002F1F29 /* Timeline */, DBB5253B2611ECF5002F1F29 /* Timeline */,
DBE3CDF1261C6B3100430CC6 /* Favorite */, DBE3CDF1261C6B3100430CC6 /* Favorite */,
DB6B74F0272FB55400C70B6E /* Follower */, DB6B74F0272FB55400C70B6E /* Follower */,
@ -3104,15 +3108,6 @@
path = Video; path = Video;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
DBB525132611EBB1002F1F29 /* Segmented */ = {
isa = PBXGroup;
children = (
DBB525262611EBDA002F1F29 /* Paging */,
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */,
);
path = Segmented;
sourceTree = "<group>";
};
DBB525262611EBDA002F1F29 /* Paging */ = { DBB525262611EBDA002F1F29 /* Paging */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -3148,6 +3143,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */,
DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */,
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */, DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
); );
path = View; path = View;
@ -3448,6 +3445,7 @@
DBA5A52E26F07ED800CACBAA /* PanModal */, DBA5A52E26F07ED800CACBAA /* PanModal */,
DB3EA911281BBEA800598866 /* AlamofireImage */, DB3EA911281BBEA800598866 /* AlamofireImage */,
DB3EA913281BBEA800598866 /* Alamofire */, DB3EA913281BBEA800598866 /* Alamofire */,
DB486C0E282E41F200F69423 /* TabBarPager */,
); );
productName = Mastodon; productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -3670,6 +3668,7 @@
DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */, DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */,
DB8D8E2D28192EED009FD90F /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, DB8D8E2D28192EED009FD90F /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */, DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */,
DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */,
); );
productRefGroup = DB427DD325BAA00100D1B89D /* Products */; productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -4037,7 +4036,6 @@
DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */, DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */, DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */,
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */,
DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */, DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */,
DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */,
@ -4394,6 +4392,7 @@
DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */,
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */,
DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
@ -4402,6 +4401,7 @@
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */,
DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */,
DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,
@ -5795,6 +5795,14 @@
minimumVersion = 5.4.0; minimumVersion = 5.4.0;
}; };
}; };
DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TwidereProject/TabBarPager.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.1.0;
};
};
DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */ = { DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git"; repositoryURL = "https://github.com/apple/swift-collections.git";
@ -5959,6 +5967,11 @@
package = DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */; package = DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire; productName = Alamofire;
}; };
DB486C0E282E41F200F69423 /* TabBarPager */ = {
isa = XCSwiftPackageProductDependency;
package = DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */;
productName = TabBarPager;
};
DB552D4E26BBD10C00E481F6 /* OrderedCollections */ = { DB552D4E26BBD10C00E481F6 /* OrderedCollections */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */; package = DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */;

View File

@ -24,7 +24,7 @@
<key>Mastodon - RTL.xcscheme_^#shared#^_</key> <key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>8</integer> <integer>7</integer>
</dict> </dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key> <key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -114,7 +114,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key> <key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>28</integer> <integer>23</integer>
</dict> </dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key> <key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -129,12 +129,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key> <key>NotificationService.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>29</integer> <integer>22</integer>
</dict> </dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key> <key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>27</integer> <integer>24</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -208,6 +208,15 @@
"version": "5.0.1" "version": "5.0.1"
} }
}, },
{
"package": "TabBarPager",
"repositoryURL": "https://github.com/TwidereProject/TabBarPager.git",
"state": {
"branch": null,
"revision": "488aa66d157a648901b61721212c0dec23d27ee5",
"version": "0.1.0"
}
},
{ {
"package": "Tabman", "package": "Tabman",
"repositoryURL": "https://github.com/uias/Tabman", "repositoryURL": "https://github.com/uias/Tabman",

View File

@ -342,6 +342,7 @@ extension SceneCoordinator {
case .custom(let transitioningDelegate): case .custom(let transitioningDelegate):
viewController.modalPresentationStyle = .custom viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = transitioningDelegate viewController.transitioningDelegate = transitioningDelegate
// viewController.modalPresentationCapturesStatusBarAppearance = true
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .customPush(let animated): case .customPush(let animated):

View File

@ -8,12 +8,12 @@
import UIKit import UIKit
protocol ScrollViewContainer: UIViewController { protocol ScrollViewContainer: UIViewController {
var scrollView: UIScrollView? { get } var scrollView: UIScrollView { get }
func scrollToTop(animated: Bool) func scrollToTop(animated: Bool)
} }
extension ScrollViewContainer { extension ScrollViewContainer {
func scrollToTop(animated: Bool) { func scrollToTop(animated: Bool) {
scrollView?.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated)
} }
} }

View File

@ -148,9 +148,7 @@ extension DiscoveryCommunityViewController: StatusTableViewCellDelegate { }
// MARK: ScrollViewContainer // MARK: ScrollViewContainer
extension DiscoveryCommunityViewController: ScrollViewContainer { extension DiscoveryCommunityViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { tableView }
tableView
}
} }
extension DiscoveryCommunityViewController { extension DiscoveryCommunityViewController {

View File

@ -130,8 +130,8 @@ extension DiscoveryViewController {
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
extension DiscoveryViewController: ScrollViewContainer { extension DiscoveryViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView {
return (currentViewController as? ScrollViewContainer)?.scrollView return (currentViewController as? ScrollViewContainer)?.scrollView ?? UIScrollView()
} }
} }

View File

@ -168,8 +168,6 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate {
// MARK: ScrollViewContainer // MARK: ScrollViewContainer
extension DiscoveryForYouViewController: ScrollViewContainer { extension DiscoveryForYouViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { tableView }
tableView
}
} }

View File

@ -127,9 +127,7 @@ extension DiscoveryHashtagsViewController: UITableViewDelegate {
// MARK: ScrollViewContainer // MARK: ScrollViewContainer
extension DiscoveryHashtagsViewController: ScrollViewContainer { extension DiscoveryHashtagsViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { tableView }
tableView
}
} }
extension DiscoveryHashtagsViewController { extension DiscoveryHashtagsViewController {

View File

@ -127,9 +127,7 @@ extension DiscoveryNewsViewController: UITableViewDelegate {
// MARK: ScrollViewContainer // MARK: ScrollViewContainer
extension DiscoveryNewsViewController: ScrollViewContainer { extension DiscoveryNewsViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { tableView }
tableView
}
} }
extension DiscoveryNewsViewController { extension DiscoveryNewsViewController {

View File

@ -160,9 +160,7 @@ extension DiscoveryPostsViewController: StatusTableViewCellDelegate { }
// MARK: ScrollViewContainer // MARK: ScrollViewContainer
extension DiscoveryPostsViewController: ScrollViewContainer { extension DiscoveryPostsViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { tableView }
tableView
}
} }
// MARK: - DiscoveryIntroBannerViewDelegate // MARK: - DiscoveryIntroBannerViewDelegate

View File

@ -537,13 +537,9 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
extension HomeTimelineViewController: ScrollViewContainer { extension HomeTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView? { return tableView } var scrollView: UIScrollView { return tableView }
func scrollToTop(animated: Bool) { func scrollToTop(animated: Bool) {
guard let scrollView = scrollView else {
return
}
if scrollView.contentOffset.y < scrollView.frame.height, if scrollView.contentOffset.y < scrollView.frame.height,
viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self),
(scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0,

View File

@ -135,6 +135,18 @@ extension MediaPreviewViewController {
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
// viewModel.$isPoping
// .receive(on: DispatchQueue.main)
// .removeDuplicates()
// .sink { [weak self] _ in
// guard let self = self else { return }
// // statusBar style update with animation
// self.setNeedsStatusBarAppearanceUpdate()
// UIView.animate(withDuration: 0.3) {
// }
// }
// .store(in: &disposeBag)
} }
} }

View File

@ -203,9 +203,7 @@ extension NotificationTimelineViewController: NotificationTableViewCellDelegate
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
extension NotificationTimelineViewController: ScrollViewContainer { extension NotificationTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { tableView }
var scrollView: UIScrollView? { tableView }
} }
extension NotificationTimelineViewController { extension NotificationTimelineViewController {

View File

@ -170,9 +170,9 @@ extension NotificationViewController {
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
extension NotificationViewController: ScrollViewContainer { extension NotificationViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView {
guard let viewController = currentViewController as? NotificationTimelineViewController else { guard let viewController = currentViewController as? NotificationTimelineViewController else {
return nil return UIScrollView()
} }
return viewController.scrollView return viewController.scrollView
} }

View File

@ -9,6 +9,9 @@ import os.log
import UIKit import UIKit
import Combine import Combine
import MetaTextKit import MetaTextKit
import MastodonLocalization
import TabBarPager
import XLPagerTabStrip
protocol ProfileAboutViewControllerDelegate: AnyObject { protocol ProfileAboutViewControllerDelegate: AnyObject {
func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta)
@ -162,7 +165,17 @@ extension ProfileAboutViewController: ProfileFieldEditCollectionViewCellDelegate
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
extension ProfileAboutViewController: ScrollViewContainer { extension ProfileAboutViewController: ScrollViewContainer {
var scrollView: UIScrollView? { var scrollView: UIScrollView { collectionView }
collectionView }
// MARK: - TabBarPage
extension ProfileAboutViewController: TabBarPage {
var pageScrollView: UIScrollView { scrollView }
}
// MARK: - IndicatorInfoProvider
extension ProfileAboutViewController: IndicatorInfoProvider {
func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
return IndicatorInfo(title: L10n.Scene.Profile.SegmentedControl.about)
} }
} }

View File

@ -25,6 +25,7 @@ extension ProfileAboutViewModel {
profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate
) )
) )
self.diffableDataSource = diffableDataSource
diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in
switch item { switch item {
@ -42,15 +43,18 @@ extension ProfileAboutViewModel {
guard case let .editField(field) = item else { continue } guard case let .editField(field) = item else { continue }
fields.append(field) fields.append(field)
} }
self.editProfileInfo.fields = fields self.profileInfoEditing.fields = fields
} }
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main])
diffableDataSource.apply(snapshot)
Publishers.CombineLatest4( Publishers.CombineLatest4(
$isEditing.removeDuplicates(), $isEditing.removeDuplicates(),
displayProfileInfo.$fields.removeDuplicates(), profileInfo.$fields.removeDuplicates(),
editProfileInfo.$fields.removeDuplicates(), profileInfoEditing.$fields.removeDuplicates(),
$emojiMeta.removeDuplicates() $emojiMeta.removeDuplicates()
) )
.throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true)

View File

@ -8,6 +8,7 @@
import os.log import os.log
import UIKit import UIKit
import Combine import Combine
import CoreDataStack
import MastodonSDK import MastodonSDK
import MastodonMeta import MastodonMeta
import Kanna import Kanna
@ -18,41 +19,69 @@ final class ProfileAboutViewModel {
// input // input
let context: AppContext let context: AppContext
@Published var user: MastodonUser?
@Published var isEditing = false @Published var isEditing = false
@Published var accountForEdit: Mastodon.Entity.Account? @Published var accountForEdit: Mastodon.Entity.Account?
@Published var emojiMeta: MastodonContent.Emojis = [:]
// output // output
var diffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>? var diffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>?
let profileInfo = ProfileInfo()
let profileInfoEditing = ProfileInfo()
let displayProfileInfo = ProfileInfo() @Published var fields: [MastodonField] = []
let editProfileInfo = ProfileInfo() @Published var emojiMeta: MastodonContent.Emojis = [:]
let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
// end init // end init
$user
.compactMap { $0 }
.flatMap { $0.publisher(for: \.emojis) }
.map { $0.asDictionary }
.assign(to: &$emojiMeta)
$user
.compactMap { $0 }
.flatMap { $0.publisher(for: \.fields) }
.assign(to: &$fields)
Publishers.CombineLatest( Publishers.CombineLatest(
$isEditing.removeDuplicates(), // only trigger when value toggle $fields,
$accountForEdit $emojiMeta
)
.map { fields, emojiMeta in
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) }
}
.assign(to: &profileInfo.$fields)
Publishers.CombineLatest(
$accountForEdit,
$emojiMeta
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, account in .sink { [weak self] account, emojiMeta in
guard let self = self else { return } guard let self = self else { return }
guard isEditing else { return } guard let account = account else { return }
// setup editing value when toggle to editing self.profileInfo.fields = account.source?.fields?.compactMap { field in
self.editProfileInfo.fields = account?.source?.fields?.compactMap { field in ProfileFieldItem.FieldValue(
name: field.name,
value: field.value,
emojiMeta: emojiMeta
)
} ?? []
self.profileInfoEditing.fields = account.source?.fields?.compactMap { field in
ProfileFieldItem.FieldValue( ProfileFieldItem.FieldValue(
name: field.name, name: field.name,
value: field.value, value: field.value,
emojiMeta: [:] // no use for editing emojiMeta: [:] // no use for editing
) )
} ?? [] } ?? []
self.editProfileInfoDidInitialized.send()
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }
} }
@ -65,31 +94,31 @@ extension ProfileAboutViewModel {
extension ProfileAboutViewModel { extension ProfileAboutViewModel {
func appendFieldItem() { func appendFieldItem() {
var fields = editProfileInfo.fields var fields = profileInfoEditing.fields
guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return }
fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:])) fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:]))
editProfileInfo.fields = fields profileInfoEditing.fields = fields
} }
func removeFieldItem(item: ProfileFieldItem) { func removeFieldItem(item: ProfileFieldItem) {
var fields = editProfileInfo.fields var fields = profileInfoEditing.fields
guard case let .editField(field) = item else { return } guard case let .editField(field) = item else { return }
guard let removeIndex = fields.firstIndex(of: field) else { return } guard let removeIndex = fields.firstIndex(of: field) else { return }
fields.remove(at: removeIndex) fields.remove(at: removeIndex)
editProfileInfo.fields = fields profileInfoEditing.fields = fields
} }
} }
// MARK: - ProfileViewModelEditable // MARK: - ProfileViewModelEditable
extension ProfileAboutViewModel: ProfileViewModelEditable { extension ProfileAboutViewModel: ProfileViewModelEditable {
func isEdited() -> Bool { var isEdited: Bool {
guard isEditing else { return false } guard isEditing else { return false }
let isFieldsEqual: Bool = { let isFieldsEqual: Bool = {
let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in
ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:]) ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:])
} ?? [] } ?? []
let editFields = editProfileInfo.fields let editFields = profileInfoEditing.fields
guard editFields.count == originalFields.count else { return false } guard editFields.count == originalFields.count else { return false }
for (editField, originalField) in zip(editFields, originalFields) { for (editField, originalField) in zip(editFields, originalFields) {
guard editField.name.value == originalField.name.value, guard editField.name.value == originalField.name.value,

View File

@ -8,6 +8,7 @@
import os.log import os.log
import UIKit import UIKit
import Combine import Combine
import CoreDataStack
import PhotosUI import PhotosUI
import AlamofireImage import AlamofireImage
import CropViewController import CropViewController
@ -15,22 +16,31 @@ import MastodonMeta
import MetaTextKit import MetaTextKit
import MastodonAsset import MastodonAsset
import MastodonLocalization import MastodonLocalization
import Tabman import TabBarPager
protocol ProfileHeaderViewControllerDelegate: AnyObject { protocol ProfileHeaderViewControllerDelegate: AnyObject {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta)
} }
final class ProfileHeaderViewController: UIViewController { final class ProfileHeaderViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "ProfileHeaderViewController", category: "ViewController")
static let segmentedControlHeight: CGFloat = 50 static let segmentedControlHeight: CGFloat = 50
static let headerMinHeight: CGFloat = segmentedControlHeight static let headerMinHeight: CGFloat = segmentedControlHeight
var disposeBag = Set<AnyCancellable>() weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var delegate: ProfileHeaderViewControllerDelegate? weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileHeaderViewModel! var viewModel: ProfileHeaderViewModel!
weak var delegate: ProfileHeaderViewControllerDelegate?
weak var headerDelegate: TabBarPagerHeaderDelegate?
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let titleView: DoubleTitleLabelNavigationBarTitleView = { let titleView: DoubleTitleLabelNavigationBarTitleView = {
let titleView = DoubleTitleLabelNavigationBarTitleView() let titleView = DoubleTitleLabelNavigationBarTitleView()
titleView.titleLabel.textColor = .white titleView.titleLabel.textColor = .white
@ -44,38 +54,7 @@ final class ProfileHeaderViewController: UIViewController {
let profileHeaderView = ProfileHeaderView() let profileHeaderView = ProfileHeaderView()
let buttonBar: TMBar.ButtonBar = { // private var isBannerPinned = false
let buttonBar = TMBar.ButtonBar()
buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
buttonBar.backgroundView.style = .clear
buttonBar.layout.contentInset = .zero
return buttonBar
}()
func customizeButtonBarAppearance() {
// The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors
// Needs trigger update when `userInterfaceStyle` chagnes
let userInterfaceStyle = traitCollection.userInterfaceStyle
buttonBar.buttons.customize { button in
switch userInterfaceStyle {
case .dark:
// Asset.Colors.Label.primary.color
button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0)
// Asset.Colors.Label.secondary.color
button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0)
default:
// Asset.Colors.Label.primary.color
button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0)
// Asset.Colors.Label.secondary.color
button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6)
}
button.backgroundColor = .clear
}
}
private var isBannerPinned = false
private var bottomShadowAlpha: CGFloat = 0.0
// private var isAdjustBannerImageViewForSafeAreaInset = false // private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero private var containerSafeAreaInset: UIEdgeInsets = .zero
@ -103,7 +82,7 @@ final class ProfileHeaderViewController: UIViewController {
}() }()
deinit { deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
} }
} }
@ -113,7 +92,7 @@ extension ProfileHeaderViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
customizeButtonBarAppearance() view.setContentHuggingPriority(.required - 1, for: .vertical)
view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
ThemeService.shared.currentTheme ThemeService.shared.currentTheme
@ -124,117 +103,15 @@ extension ProfileHeaderViewController {
} }
.store(in: &disposeBag) .store(in: &disposeBag)
// profileHeaderView.preservesSuperviewLayoutMargins = true
profileHeaderView.translatesAutoresizingMaskIntoConstraints = false profileHeaderView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(profileHeaderView) view.addSubview(profileHeaderView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor),
profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor),
]) ])
profileHeaderView.preservesSuperviewLayoutMargins = true
Publishers.CombineLatest(
viewModel.viewDidAppear.eraseToAnyPublisher(),
viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSet in
guard let self = self else { return }
self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0
self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0
}
.store(in: &disposeBag)
viewModel.needsSetupBottomShadow
.receive(on: DispatchQueue.main)
.sink { [weak self] needsSetupBottomShadow in
guard let self = self else { return }
self.setupBottomShadow()
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
viewModel.$isEditing.eraseToAnyPublisher(),
viewModel.displayProfileInfo.$avatarImageResource.eraseToAnyPublisher(),
viewModel.editProfileInfo.$avatarImageResource.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, displayResource, editingResource, _ in
guard let self = self else { return }
let url = displayResource.url
let image = editingResource.image
self.profileHeaderView.avatarButton.avatarImageView.configure(
configuration: AvatarImageView.Configuration(
url: isEditing && image != nil ? nil : url,
placeholder: image ?? UIImage.placeholder(color: Asset.Theme.Mastodon.systemGroupedBackground.color)
)
)
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
viewModel.$isEditing,
viewModel.displayProfileInfo.$name.removeDuplicates(),
viewModel.editProfileInfo.$name.removeDuplicates(),
viewModel.$emojiMeta
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, name, editingName, emojiMeta in
guard let self = self else { return }
do {
let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
self.profileHeaderView.nameMetaText.configure(content: metaContent)
} catch {
assertionFailure()
}
self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
}
.store(in: &disposeBag)
let profileNote = Publishers.CombineLatest3(
viewModel.$isEditing.removeDuplicates(),
viewModel.displayProfileInfo.$note.removeDuplicates(),
viewModel.editProfileInfoDidInitialized
)
.map { isEditing, displayNote, _ -> String? in
if isEditing {
return self.viewModel.editProfileInfo.note
} else {
return displayNote
}
}
.eraseToAnyPublisher()
Publishers.CombineLatest3(
viewModel.$isEditing.removeDuplicates(),
profileNote.removeDuplicates(),
viewModel.$emojiMeta.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, note, emojiMeta in
guard let self = self else { return }
self.profileHeaderView.bioMetaText.textView.isEditable = isEditing
if isEditing {
let metaContent = PlaintextMetaContent(string: note ?? "")
self.profileHeaderView.bioMetaText.configure(content: metaContent)
} else {
let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta)
do {
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
self.profileHeaderView.bioMetaText.configure(content: metaContent)
} catch {
assertionFailure()
self.profileHeaderView.bioMetaText.reset()
}
}
}
.store(in: &disposeBag)
profileHeaderView.bioMetaText.delegate = self profileHeaderView.bioMetaText.delegate = self
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField)
@ -242,18 +119,56 @@ extension ProfileHeaderViewController {
.sink { [weak self] notification in .sink { [weak self] notification in
guard let self = self else { return } guard let self = self else { return }
guard let textField = notification.object as? UITextField else { return } guard let textField = notification.object as? UITextField else { return }
self.viewModel.editProfileInfo.name = textField.text self.viewModel.profileInfoEditing.name = textField.text
} }
.store(in: &disposeBag) .store(in: &disposeBag)
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true
profileHeaderView.delegate = self
// bind viewModel
viewModel.$isTitleViewContentOffsetSet
.receive(on: DispatchQueue.main)
.sink { [weak self] isTitleViewContentOffsetDidSet in
guard let self = self else { return }
self.titleView.titleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0
self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0
}
.store(in: &disposeBag)
viewModel.$user
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
guard let self = self else { return }
guard let user = user else { return }
self.profileHeaderView.prepareForReuse()
self.profileHeaderView.configuration(user: user)
}
.store(in: &disposeBag)
viewModel.$relationshipActionOptionSet
.assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.$isEditing
.assign(to: \.isEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.$isUpdating
.assign(to: \.isUpdating, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$avatar
.assign(to: \.avatarImageEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$name
.assign(to: \.nameEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$note
.assign(to: \.noteEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true profileHeaderView.viewModel.viewDidAppear.send()
// set display after view appear // set display after view appear
profileHeaderView.setupAvatarOverlayViews() profileHeaderView.setupAvatarOverlayViews()
@ -262,14 +177,7 @@ extension ProfileHeaderViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) headerDelegate?.viewLayoutDidUpdate(self)
setupBottomShadow()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
customizeButtonBarAppearance()
} }
} }
@ -321,56 +229,8 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset containerSafeAreaInset = inset
} }
func setupBottomShadow() {
guard viewModel.needsSetupBottomShadow.value else {
view.layer.shadowColor = nil
view.layer.shadowRadius = 0
return
}
view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
}
private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha {
bottomShadowAlpha = alpha
view.setNeedsLayout()
}
}
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) { func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
updateHeaderBottomShadow(progress: progress)
let bannerImageView = profileHeaderView.bannerImageView
guard bannerImageView.bounds != .zero else {
// wait layout finish
return
}
let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
// scroll from bottom to top: 1 -> 2 -> 3
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
// 1
// banner top pin to window top and expand
bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
} else if bannerContainerBottomOffset < containerSafeAreaInset.top {
// 3
// banner bottom pin to navigation bar bottom and
// the `progress` growth to 1 then segmented control pin to top
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
bannerImageView.frame.size.height = bannerImageHeight
} else {
// 2
// banner move with scrolling from bottom to top until the
// banner bottom higher than navigation bar bottom
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top
}
// set title view offset // set title view offset
let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
@ -378,18 +238,14 @@ extension ProfileHeaderViewController {
let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
let transformY = max(0, titleViewContentOffset) let transformY = max(0, titleViewContentOffset)
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height viewModel.isTitleViewDisplaying = transformY < titleView.containerView.frame.height
viewModel.isTitleViewContentOffsetSet = true
if viewModel.viewDidAppear.value { if progress > 0, throttle > 0 {
viewModel.isTitleViewContentOffsetSet.value = true // y = 1 - (x/t)
} // give: x = 0, y = 1
// x = t, y = 0
// set avatar fade let alpha = 1 - progress/throttle
if progress > 0 {
setProfileAvatar(alpha: 0)
} else if progress > -abs(throttle) {
// y = -(1/0.8T)x
let alpha = -1 / abs(0.8 * throttle) * progress
setProfileAvatar(alpha: alpha) setProfileAvatar(alpha: alpha)
} else { } else {
setProfileAvatar(alpha: 1) setProfileAvatar(alpha: 1)
@ -404,6 +260,103 @@ extension ProfileHeaderViewController {
} }
// MARK: - ProfileHeaderViewDelegate
extension ProfileHeaderViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) {
guard let user = viewModel.user else { return }
let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID)
Task {
try await DataSourceFacade.coordinateToMediaPreviewScene(
dependency: self,
user: record,
previewContext: DataSourceFacade.ImagePreviewContext(
imageView: button.avatarImageView,
containerView: .profileAvatar(profileHeaderView)
)
)
} // end Task
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) {
guard let user = viewModel.user else { return }
let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID)
Task {
try await DataSourceFacade.coordinateToMediaPreviewScene(
dependency: self,
user: record,
previewContext: DataSourceFacade.ImagePreviewContext(
imageView: imageView,
containerView: .profileBanner(profileHeaderView)
)
)
} // end Task
}
func profileHeaderView(
_ profileHeaderView: ProfileHeaderView,
relationshipButtonDidPressed button: ProfileRelationshipActionButton
) {
delegate?.profileHeaderViewController(
self,
profileHeaderView: profileHeaderView,
relationshipButtonDidPressed: button
)
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) {
delegate?.profileHeaderViewController(
self,
profileHeaderView: profileHeaderView,
metaTextView: metaTextView,
metaDidPressed: meta
)
}
func profileHeaderView(
_ profileHeaderView: ProfileHeaderView,
profileStatusDashboardView dashboardView: ProfileStatusDashboardView,
dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView,
meter: ProfileStatusDashboardView.Meter
) {
switch meter {
case .post:
// do nothing
break
case .follower:
guard let domain = viewModel.user?.domain,
let userID = viewModel.user?.id
else { return }
let followerListViewModel = FollowerListViewModel(
context: context,
domain: domain,
userID: userID
)
coordinator.present(
scene: .follower(viewModel: followerListViewModel),
from: self,
transition: .show
)
case .following:
guard let domain = viewModel.user?.domain,
let userID = viewModel.user?.id
else { return }
let followingListViewModel = FollowingListViewModel(
context: context,
domain: domain,
userID: userID
)
coordinator.present(
scene: .following(viewModel: followingListViewModel),
from: self,
transition: .show
)
}
}
}
// MARK: - MetaTextDelegate // MARK: - MetaTextDelegate
extension ProfileHeaderViewController: MetaTextDelegate { extension ProfileHeaderViewController: MetaTextDelegate {
func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? {
@ -412,7 +365,9 @@ extension ProfileHeaderViewController: MetaTextDelegate {
switch metaText { switch metaText {
case profileHeaderView.bioMetaText: case profileHeaderView.bioMetaText:
guard viewModel.isEditing else { break } guard viewModel.isEditing else { break }
viewModel.editProfileInfo.note = metaText.backedString defer {
viewModel.profileInfoEditing.note = metaText.backedString
}
let metaContent = PlaintextMetaContent(string: metaText.backedString) let metaContent = PlaintextMetaContent(string: metaText.backedString)
return metaContent return metaContent
default: default:
@ -484,7 +439,10 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate {
// MARK: - CropViewControllerDelegate // MARK: - CropViewControllerDelegate
extension ProfileHeaderViewController: CropViewControllerDelegate { extension ProfileHeaderViewController: CropViewControllerDelegate {
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
viewModel.editProfileInfo.avatarImage = image viewModel.profileInfoEditing.avatar = image
cropViewController.dismiss(animated: true, completion: nil) cropViewController.dismiss(animated: true, completion: nil)
} }
} }
// MARK: - TabBarPagerHeader
extension ProfileHeaderViewController: TabBarPagerHeader { }

View File

@ -8,9 +8,11 @@
import os.log import os.log
import UIKit import UIKit
import Combine import Combine
import CoreDataStack
import Kanna import Kanna
import MastodonSDK import MastodonSDK
import MastodonMeta import MastodonMeta
import MastodonUI
final class ProfileHeaderViewModel { final class ProfileHeaderViewModel {
@ -21,37 +23,42 @@ final class ProfileHeaderViewModel {
// input // input
let context: AppContext let context: AppContext
@Published var isEditing = false @Published var user: MastodonUser?
@Published var accountForEdit: Mastodon.Entity.Account? @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none
@Published var emojiMeta: MastodonContent.Emojis = [:]
let viewDidAppear = CurrentValueSubject<Bool, Never>(false) @Published var isEditing = false
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true) @Published var isUpdating = false
let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false)
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false) @Published var accountForEdit: Mastodon.Entity.Account?
// let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false)
// output // output
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false) let profileInfo = ProfileInfo()
let displayProfileInfo = ProfileInfo() let profileInfoEditing = ProfileInfo()
let editProfileInfo = ProfileInfo()
let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event @Published var isTitleViewDisplaying = false
@Published var isTitleViewContentOffsetSet = false
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
Publishers.CombineLatest(
$isEditing.removeDuplicates(), // only trigger when value toggle
$accountForEdit $accountForEdit
)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, account in .sink { [weak self] account in
guard let self = self else { return } guard let self = self else { return }
guard isEditing else { return } guard let account = account else { return }
// setup editing value when toggle to editing // avatar
self.editProfileInfo.name = self.displayProfileInfo.name // set to name self.profileInfo.avatar = nil
self.editProfileInfo.avatarImage = nil // set to empty self.profileInfoEditing.avatar = nil
self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note) // name
self.editProfileInfoDidInitialized.send() let name = account.displayNameWithFallback
self.profileInfo.name = name
self.profileInfoEditing.name = name
// bio
let note = ProfileHeaderViewModel.normalize(note: account.note)
self.profileInfo.note = note
self.profileInfoEditing.note = note
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }
@ -61,29 +68,9 @@ final class ProfileHeaderViewModel {
extension ProfileHeaderViewModel { extension ProfileHeaderViewModel {
class ProfileInfo { class ProfileInfo {
// input // input
@Published var avatar: UIImage?
@Published var name: String? @Published var name: String?
@Published var avatarImageURL: URL?
@Published var avatarImage: UIImage?
@Published var note: String? @Published var note: String?
// output
@Published var avatarImageResource = ImageResource(url: nil, image: nil)
struct ImageResource {
let url: URL?
let image: UIImage?
}
init() {
Publishers.CombineLatest(
$avatarImageURL,
$avatarImage
)
.map { url, image in
ImageResource(url: url, image: image)
}
.assign(to: &$avatarImageResource)
}
} }
} }
@ -103,15 +90,14 @@ extension ProfileHeaderViewModel {
} }
// MARK: - ProfileViewModelEditable // MARK: - ProfileViewModelEditable
extension ProfileHeaderViewModel: ProfileViewModelEditable { extension ProfileHeaderViewModel: ProfileViewModelEditable {
func isEdited() -> Bool { var isEdited: Bool {
guard isEditing else { return false } guard isEditing else { return false }
guard editProfileInfo.name == displayProfileInfo.name else { return true } guard profileInfoEditing.avatar == nil else { return true }
guard editProfileInfo.avatarImage == nil else { return true } guard profileInfo.name == profileInfoEditing.name else { return true }
guard editProfileInfo.note == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note) else { return true } guard profileInfo.note == profileInfoEditing.note else { return true }
return false return false
} }

View File

@ -0,0 +1,56 @@
//
// ProfileHeaderView+Configuration.swift
// Mastodon
//
// Created by MainasuK on 2022-5-26.
//
import os.log
import UIKit
import Combine
import CoreDataStack
extension ProfileHeaderView {
func configuration(user: MastodonUser) {
// header
user.publisher(for: \.header)
.map { _ in user.headerImageURL() }
.assign(to: \.headerImageURL, on: viewModel)
.store(in: &disposeBag)
// avatar
user.publisher(for: \.avatar)
.map { _ in user.avatarImageURL() }
.assign(to: \.avatarImageURL, on: viewModel)
.store(in: &disposeBag)
// emojiMeta
user.publisher(for: \.emojis)
.map { $0.asDictionary }
.assign(to: \.emojiMeta, on: viewModel)
.store(in: &disposeBag)
// name
user.publisher(for: \.displayName)
.map { _ in user.displayNameWithFallback }
.assign(to: \.name, on: viewModel)
.store(in: &disposeBag)
// username
viewModel.username = user.username
// bio
user.publisher(for: \.note)
.assign(to: \.note, on: viewModel)
.store(in: &disposeBag)
// dashboard
user.publisher(for: \.statusesCount)
.map { Int($0) }
.assign(to: \.statusesCount, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.followingCount)
.map { Int($0) }
.assign(to: \.followingCount, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.followersCount)
.map { Int($0) }
.assign(to: \.followersCount, on: viewModel)
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,280 @@
//
// ProfileHeaderView+ViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-5-26.
//
import os.log
import UIKit
import Combine
import CoreDataStack
import MetaTextKit
import MastodonMeta
import MastodonUI
import MastodonAsset
import MastodonLocalization
extension ProfileHeaderView {
class ViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
let viewDidAppear = PassthroughSubject<Void, Never>()
@Published var state: State?
@Published var isEditing = false
@Published var isUpdating = false
@Published var emojiMeta: MastodonContent.Emojis = [:]
@Published var headerImageURL: URL?
@Published var avatarImageURL: URL?
@Published var avatarImageEditing: UIImage?
@Published var name: String?
@Published var nameEditing: String?
@Published var username: String?
@Published var note: String?
@Published var noteEditing: String?
@Published var statusesCount: Int?
@Published var followingCount: Int?
@Published var followersCount: Int?
@Published var fields: [MastodonField] = []
@Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none
@Published var isRelationshipActionButtonHidden = false
init() {
$relationshipActionOptionSet
.compactMap { $0.highPriorityAction(except: []) }
.map { $0 == .none }
.assign(to: &$isRelationshipActionButtonHidden)
}
}
}
extension ProfileHeaderView.ViewModel {
func bind(view: ProfileHeaderView) {
// header
Publishers.CombineLatest(
$headerImageURL,
viewDidAppear
)
.sink { headerImageURL, _ in
view.bannerImageView.af.cancelImageRequest()
let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
guard let bannerImageURL = headerImageURL else {
view.bannerImageView.image = placeholder
return
}
view.bannerImageView.af.setImage(
withURL: bannerImageURL,
placeholderImage: placeholder,
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: { [weak view] response in
guard let view = view else { return }
guard let image = response.value else { return }
guard image.size.width > 1 && image.size.height > 1 else {
// restore to placeholder when image invalid
view.bannerImageView.image = placeholder
return
}
}
)
}
.store(in: &disposeBag)
// avatar
Publishers.CombineLatest4(
$avatarImageURL,
$avatarImageEditing,
$isEditing,
viewDidAppear
)
.sink { avatarImageURL, avatarImageEditing, isEditing, _ in
view.avatarButton.avatarImageView.configure(configuration: .init(
url: (!isEditing || avatarImageEditing == nil) ? avatarImageURL : nil,
placeholder: isEditing ? (avatarImageEditing ?? AvatarImageView.placeholder) : AvatarImageView.placeholder
))
}
.store(in: &disposeBag)
// blur
$relationshipActionOptionSet
.map { $0.contains(.blocking) || $0.contains(.blockingBy) }
.sink { needsImageOverlayBlurred in
UIView.animate(withDuration: 0.33) {
let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil
view.bannerImageViewOverlayVisualEffectView.effect = bannerEffect
let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil
view.avatarImageViewOverlayVisualEffectView.effect = avatarEffect
}
}
.store(in: &disposeBag)
// name
Publishers.CombineLatest4(
$isEditing.removeDuplicates(),
$name.removeDuplicates(),
$nameEditing.removeDuplicates(),
$emojiMeta.removeDuplicates()
)
.sink { isEditing, name, nameEditing, emojiMeta in
do {
let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
view.nameMetaText.configure(content: metaContent)
} catch {
assertionFailure()
}
view.nameTextField.text = isEditing ? nameEditing : name
}
.store(in: &disposeBag)
// username
$username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.assign(to: \.text, on: view.usernameLabel)
.store(in: &disposeBag)
// bio
Publishers.CombineLatest4(
$isEditing.removeDuplicates(),
$emojiMeta.removeDuplicates(),
$note.removeDuplicates(),
$noteEditing.removeDuplicates()
)
.sink { isEditing, emojiMeta, note, noteEditing in
view.bioMetaText.textView.isEditable = isEditing
let metaContent: MetaContent = {
if isEditing {
return PlaintextMetaContent(string: noteEditing ?? "")
} else {
do {
let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta)
return try MastodonMetaContent.convert(document: mastodonContent)
} catch {
assertionFailure()
return PlaintextMetaContent(string: note ?? "")
}
}
}()
guard metaContent.string != view.bioMetaText.textStorage.string else { return }
view.bioMetaText.configure(content: metaContent)
}
.store(in: &disposeBag)
$relationshipActionOptionSet
.sink { optionSet in
let isBlocking = optionSet.contains(.blocking)
let isBlockedBy = optionSet.contains(.blockingBy)
let isSuspended = optionSet.contains(.suspended)
let isNeedsHidden = isBlocking || isBlockedBy || isSuspended
view.bioMetaText.textView.isHidden = isNeedsHidden
}
.store(in: &disposeBag)
// dashboard
$statusesCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.postDashboardMeterView.numberLabel.text = text
view.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0)
}
.store(in: &disposeBag)
$followingCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
view.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0)
}
.store(in: &disposeBag)
$followersCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
view.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0)
}
.store(in: &disposeBag)
$isEditing
.sink { isEditing in
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
animator.addAnimations {
view.statusDashboardView.alpha = isEditing ? 0.2 : 1.0
}
animator.startAnimation()
}
.store(in: &disposeBag)
// relationship
$isRelationshipActionButtonHidden
.assign(to: \.isHidden, on: view.relationshipActionButtonShadowContainer)
.store(in: &disposeBag)
Publishers.CombineLatest3(
$relationshipActionOptionSet,
$isEditing,
$isUpdating
)
.sink { relationshipActionOptionSet, isEditing, isUpdating in
if relationshipActionOptionSet.contains(.edit) {
// check .edit state and set .editing when isEditing
view.relationshipActionButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit))
view.configure(state: isEditing ? .editing : .normal)
} else {
view.relationshipActionButton.configure(actionOptionSet: relationshipActionOptionSet)
}
}
.store(in: &disposeBag)
}
}
extension ProfileHeaderView {
enum State {
case normal
case editing
}
func configure(state: State) {
guard viewModel.state != state else { return } // avoid redundant animation
viewModel.state = state
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
switch state {
case .normal:
nameMetaText.textView.alpha = 1
nameTextField.alpha = 0
nameTextField.isEnabled = false
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
self.nameTextFieldBackgroundView.backgroundColor = .clear
self.editAvatarBackgroundView.alpha = 0
}
animator.addCompletion { _ in
self.editAvatarBackgroundView.isHidden = true
}
case .editing:
nameMetaText.textView.alpha = 0
nameTextField.isEnabled = true
nameTextField.alpha = 1
editAvatarBackgroundView.isHidden = false
editAvatarBackgroundView.alpha = 0
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
}
}
animator.startAnimation()
}
}

View File

@ -38,7 +38,15 @@ final class ProfileHeaderView: UIView {
weak var delegate: ProfileHeaderViewDelegate? weak var delegate: ProfileHeaderViewDelegate?
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var state: State? func prepareForReuse() {
disposeBag.removeAll()
}
private(set) lazy var viewModel: ViewModel = {
let viewModel = ViewModel()
viewModel.bind(view: self)
return viewModel
}()
let bannerContainerView = UIView() let bannerContainerView = UIView()
let bannerImageView: UIImageView = { let bannerImageView: UIImageView = {
@ -61,6 +69,8 @@ final class ProfileHeaderView: UIView {
overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
return overlayView return overlayView
}() }()
var bannerImageViewTopLayoutConstraint: NSLayoutConstraint!
var bannerImageViewBottomLayoutConstraint: NSLayoutConstraint!
let avatarImageViewBackgroundView: UIView = { let avatarImageViewBackgroundView: UIView = {
let view = UIView() let view = UIView()
@ -81,7 +91,7 @@ final class ProfileHeaderView: UIView {
func setupAvatarOverlayViews() { func setupAvatarOverlayViews() {
editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6) editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6)
editAvatarButton.tintColor = .white editAvatarButtonOverlayIndicatorView.tintColor = .white
} }
static let avatarImageViewOverlayBlurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark) static let avatarImageViewOverlayBlurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark)
@ -101,7 +111,7 @@ final class ProfileHeaderView: UIView {
return view return view
}() }()
let editAvatarButton: HighlightDimmableButton = { let editAvatarButtonOverlayIndicatorView: HighlightDimmableButton = {
let button = HighlightDimmableButton() let button = HighlightDimmableButton()
button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
button.tintColor = .clear button.tintColor = .clear
@ -136,7 +146,7 @@ final class ProfileHeaderView: UIView {
let nameTextField: UITextField = { let nameTextField: UITextField = {
let textField = UITextField() let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold))
textField.textColor = Asset.Colors.Label.secondary.color textField.textColor = Asset.Colors.Label.primary.color
textField.text = "Alice" textField.text = "Alice"
textField.autocorrectionType = .no textField.autocorrectionType = .no
textField.autocapitalizationType = .none textField.autocapitalizationType = .none
@ -164,8 +174,8 @@ final class ProfileHeaderView: UIView {
return button return button
}() }()
let bioContainerView = UIView() // let bioContainerView = UIView()
let fieldContainerStackView = UIStackView() // let fieldContainerStackView = UIStackView()
let bioMetaText: MetaText = { let bioMetaText: MetaText = {
let metaText = MetaText() let metaText = MetaText()
@ -230,12 +240,19 @@ extension ProfileHeaderView {
bannerContainerView.topAnchor.constraint(equalTo: topAnchor), bannerContainerView.topAnchor.constraint(equalTo: topAnchor),
bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor),
readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width bannerContainerView.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // aspectRatio 1 : 3
]) ])
bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] bannerImageView.translatesAutoresizingMaskIntoConstraints = false
bannerImageView.frame = bannerContainerView.bounds
bannerContainerView.addSubview(bannerImageView) bannerContainerView.addSubview(bannerImageView)
bannerImageViewTopLayoutConstraint = bannerImageView.topAnchor.constraint(equalTo: bannerContainerView.topAnchor)
bannerImageViewBottomLayoutConstraint = bannerContainerView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor)
NSLayoutConstraint.activate([
bannerImageViewTopLayoutConstraint,
bannerImageView.leadingAnchor.constraint(equalTo: bannerContainerView.leadingAnchor),
bannerImageView.trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor),
bannerImageViewBottomLayoutConstraint,
])
bannerImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false bannerImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
bannerImageView.addSubview(bannerImageViewOverlayVisualEffectView) bannerImageView.addSubview(bannerImageViewOverlayVisualEffectView)
@ -283,13 +300,13 @@ extension ProfileHeaderView {
editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor),
]) ])
editAvatarButton.translatesAutoresizingMaskIntoConstraints = false editAvatarButtonOverlayIndicatorView.translatesAutoresizingMaskIntoConstraints = false
editAvatarBackgroundView.addSubview(editAvatarButton) editAvatarBackgroundView.addSubview(editAvatarButtonOverlayIndicatorView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), editAvatarButtonOverlayIndicatorView.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), editAvatarButtonOverlayIndicatorView.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), editAvatarButtonOverlayIndicatorView.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), editAvatarButtonOverlayIndicatorView.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
]) ])
editAvatarBackgroundView.isUserInteractionEnabled = true editAvatarBackgroundView.isUserInteractionEnabled = true
avatarButton.isUserInteractionEnabled = true avatarButton.isUserInteractionEnabled = true
@ -297,6 +314,7 @@ extension ProfileHeaderView {
// container: V - [ dashboard container | author container | bio ] // container: V - [ dashboard container | author container | bio ]
let container = UIStackView() let container = UIStackView()
container.axis = .vertical container.axis = .vertical
container.distribution = .fill
container.spacing = 8 container.spacing = 8
container.preservesSuperviewLayoutMargins = true container.preservesSuperviewLayoutMargins = true
container.isLayoutMarginsRelativeArrangement = true container.isLayoutMarginsRelativeArrangement = true
@ -364,6 +382,7 @@ extension ProfileHeaderView {
nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5), nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5),
nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor), nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor),
]) ])
// nameMetaText.textView.setContentHuggingPriority(, for: <#T##NSLayoutConstraint.Axis#>)
nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(displayNameStackView)
nameContainerStackView.addArrangedSubview(usernameLabel) nameContainerStackView.addArrangedSubview(usernameLabel)
@ -438,53 +457,6 @@ extension ProfileHeaderView {
} }
extension ProfileHeaderView {
enum State {
case normal
case editing
}
func configure(state: State) {
guard self.state != state else { return } // avoid redundant animation
self.state = state
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
switch state {
case .normal:
nameMetaText.textView.alpha = 1
nameTextField.alpha = 0
nameTextField.isEnabled = false
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
self.nameTextFieldBackgroundView.backgroundColor = .clear
self.editAvatarBackgroundView.alpha = 0
}
animator.addCompletion { _ in
self.editAvatarBackgroundView.isHidden = true
}
case .editing:
nameMetaText.textView.alpha = 0
nameTextField.isEnabled = true
nameTextField.alpha = 1
editAvatarBackgroundView.isHidden = false
editAvatarBackgroundView.alpha = 0
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
}
}
animator.startAnimation()
}
}
extension ProfileHeaderView { extension ProfileHeaderView {
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -0,0 +1,217 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Combine
import XLPagerTabStrip
import TabBarPager
import MastodonAsset
protocol ProfilePagingViewControllerDelegate: AnyObject {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int)
}
final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController {
weak var tabBarPageViewDelegate: TabBarPageViewDelegate?
weak var pagingDelegate: ProfilePagingViewControllerDelegate?
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfilePagingViewModel!
let buttonBarShadowView = UIView()
private var buttonBarShadowAlpha: CGFloat = 0.0
// MARK: - TabBarPageViewController
var currentPage: TabBarPage? {
return viewModel.viewControllers[currentIndex]
}
var currentPageIndex: Int? {
currentIndex
}
// MARK: - ButtonBarPagerTabStripViewController
override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
return viewModel.viewControllers
}
override func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) {
super.updateIndicator(for: viewController, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: progressPercentage, indexWasChanged: indexWasChanged)
guard indexWasChanged else { return }
let page = viewModel.viewControllers[toIndex]
tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex)
}
// make key commands works
override var canBecomeFirstResponder: Bool {
return true
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfilePagingViewController {
override func viewDidLoad() {
// configure style before viewDidLoad
settings.style.buttonBarBackgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
settings.style.buttonBarItemBackgroundColor = .clear
settings.style.buttonBarItemsShouldFillAvailableWidth = false // alignment from leading to trailing
settings.style.selectedBarHeight = 3
settings.style.selectedBarBackgroundColor = Asset.Colors.Label.primary.color
settings.style.buttonBarItemFont = UIFont.systemFont(ofSize: 17, weight: .semibold)
changeCurrentIndexProgressive = { [weak self] (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, progressPercentage: CGFloat, changeCurrentIndex: Bool, animated: Bool) -> Void in
guard let _ = self else { return }
guard changeCurrentIndex == true else { return }
oldCell?.label.textColor = Asset.Colors.Label.secondary.color
newCell?.label.textColor = Asset.Colors.Label.primary.color
}
super.viewDidLoad()
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.settings.style.buttonBarBackgroundColor = theme.systemBackgroundColor
self.barButtonLayout?.invalidateLayout()
}
.store(in: &disposeBag)
updateBarButtonInsets()
if let buttonBarView = self.buttonBarView {
buttonBarShadowView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(buttonBarShadowView, belowSubview: buttonBarView)
NSLayoutConstraint.activate([
buttonBarShadowView.topAnchor.constraint(equalTo: buttonBarView.topAnchor),
buttonBarShadowView.leadingAnchor.constraint(equalTo: buttonBarView.leadingAnchor),
buttonBarShadowView.trailingAnchor.constraint(equalTo: buttonBarView.trailingAnchor),
buttonBarShadowView.bottomAnchor.constraint(equalTo: buttonBarView.bottomAnchor),
])
viewModel.$needsSetupBottomShadow
.receive(on: DispatchQueue.main)
.sink { [weak self] needsSetupBottomShadow in
guard let self = self else { return }
self.setupBottomShadow()
}
.store(in: &disposeBag)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
setupBottomShadow()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateBarButtonInsets()
}
}
extension ProfilePagingViewController {
private func updateBarButtonInsets() {
let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom {
case .phone:
return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
default:
return traitCollection.horizontalSizeClass == .regular ?
ProfileViewController.containerViewMarginForRegularHorizontalSizeClass :
ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
}
}()
settings.style.buttonBarLeftContentInset = margin
settings.style.buttonBarRightContentInset = margin
barButtonLayout?.sectionInset.left = margin
barButtonLayout?.sectionInset.right = margin
barButtonLayout?.invalidateLayout()
}
private var barButtonLayout: UICollectionViewFlowLayout? {
let layout = buttonBarView.collectionViewLayout as? UICollectionViewFlowLayout
return layout
}
func setupBottomShadow() {
guard viewModel.needsSetupBottomShadow else {
buttonBarShadowView.layer.shadowColor = nil
buttonBarShadowView.layer.shadowRadius = 0
return
}
buttonBarShadowView.layer.setupShadow(
color: UIColor.black.withAlphaComponent(0.12),
alpha: Float(buttonBarShadowAlpha),
x: 0,
y: 2,
blur: 2,
spread: 0,
roundedRect: buttonBarShadowView.bounds,
byRoundingCorners: .allCorners,
cornerRadii: .zero
)
}
func updateButtonBarShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if buttonBarShadowAlpha != alpha {
buttonBarShadowAlpha = alpha
setupBottomShadow()
buttonBarShadowView.setNeedsLayout()
}
}
}
extension ProfilePagingViewController {
var currentViewController: (UIViewController & TabBarPage)? {
guard !viewModel.viewControllers.isEmpty,
currentIndex < viewModel.viewControllers.count
else { return nil }
return viewModel.viewControllers[currentIndex]
}
}
// workaround to fix tab man responder chain issue
extension ProfilePagingViewController {
override var keyCommands: [UIKeyCommand]? {
return currentViewController?.keyCommands
}
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender)
}
}

View File

@ -0,0 +1,50 @@
//
// ProfilePagingViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import MastodonAsset
import MastodonLocalization
import TabBarPager
final class ProfilePagingViewModel: NSObject {
let postUserTimelineViewController = UserTimelineViewController()
let repliesUserTimelineViewController = UserTimelineViewController()
let mediaUserTimelineViewController = UserTimelineViewController()
let profileAboutViewController = ProfileAboutViewController()
// input
@Published var needsSetupBottomShadow = true
init(
postsUserTimelineViewModel: UserTimelineViewModel,
repliesUserTimelineViewModel: UserTimelineViewModel,
mediaUserTimelineViewModel: UserTimelineViewModel,
profileAboutViewModel: ProfileAboutViewModel
) {
postUserTimelineViewController.viewModel = postsUserTimelineViewModel
repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel
mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel
profileAboutViewController.viewModel = profileAboutViewModel
super.init()
}
var viewControllers: [UIViewController & TabBarPage] {
return [
postUserTimelineViewController,
repliesUserTimelineViewController,
mediaUserTimelineViewController,
profileAboutViewController,
]
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -27,88 +27,100 @@ class ProfileViewModel: NSObject {
private var mastodonUserObserver: AnyCancellable? private var mastodonUserObserver: AnyCancellable?
private var currentMastodonUserObserver: AnyCancellable? private var currentMastodonUserObserver: AnyCancellable?
let postsUserTimelineViewModel: UserTimelineViewModel
let repliesUserTimelineViewModel: UserTimelineViewModel
let mediaUserTimelineViewModel: UserTimelineViewModel
let profileAboutViewModel: ProfileAboutViewModel
// input // input
let context: AppContext let context: AppContext
@Published var me: MastodonUser? @Published var me: MastodonUser?
@Published var user: MastodonUser? @Published var user: MastodonUser?
let viewDidAppear = PassthroughSubject<Void, Never>() let viewDidAppear = PassthroughSubject<Void, Never>()
@Published var isEditing = false
@Published var isUpdating = false
@Published var accountForEdit: Mastodon.Entity.Account?
// output // output
let domain: CurrentValueSubject<String?, Never> let relationshipViewModel = RelationshipViewModel()
let userID: CurrentValueSubject<UserID?, Never>
let bannerImageURL: CurrentValueSubject<URL?, Never>
let avatarImageURL: CurrentValueSubject<URL?, Never>
let name: CurrentValueSubject<String?, Never>
let username: CurrentValueSubject<String?, Never>
let bioDescription: CurrentValueSubject<String?, Never>
let url: CurrentValueSubject<String?, Never>
let statusesCount: CurrentValueSubject<Int?, Never>
let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never>
let fields: CurrentValueSubject<[MastodonField], Never>
let emojiMeta: CurrentValueSubject<MastodonContent.Emojis, Never>
// fulfill this before editing @Published var userIdentifier: UserIdentifier? = nil
let accountForEdit = CurrentValueSubject<Mastodon.Entity.Account?, Never>(nil)
let protected: CurrentValueSubject<Bool?, Never> @Published var isRelationshipActionButtonHidden: Bool = true
let suspended: CurrentValueSubject<Bool, Never> @Published var isReplyBarButtonItemHidden: Bool = true
@Published var isMoreMenuBarButtonItemHidden: Bool = true
@Published var isMeBarButtonItemsHidden: Bool = true
@Published var isPagingEnabled = true
let isEditing = CurrentValueSubject<Bool, Never>(false) // @Published var protected: Bool? = nil
let isUpdating = CurrentValueSubject<Bool, Never>(false) // let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
let isFollowedBy = CurrentValueSubject<Bool, Never>(false)
let isMuting = CurrentValueSubject<Bool, Never>(false)
let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true)
let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
let needsPagingEnabled = CurrentValueSubject<Bool, Never>(true)
let needsImageOverlayBlurred = CurrentValueSubject<Bool, Never>(false)
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context self.context = context
self.user = mastodonUser self.user = mastodonUser
self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) self.postsUserTimelineViewModel = UserTimelineViewModel(
self.userID = CurrentValueSubject(mastodonUser?.id) context: context,
self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) title: L10n.Scene.Profile.SegmentedControl.posts,
self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) queryFilter: .init(excludeReplies: true)
self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) )
self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.repliesUserTimelineViewModel = UserTimelineViewModel(
self.bioDescription = CurrentValueSubject(mastodonUser?.note) context: context,
self.url = CurrentValueSubject(mastodonUser?.url) title: L10n.Scene.Profile.SegmentedControl.postsAndReplies,
self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) }) queryFilter: .init(excludeReplies: true)
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) }) )
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) }) self.mediaUserTimelineViewModel = UserTimelineViewModel(
self.protected = CurrentValueSubject(mastodonUser?.locked) context: context,
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) title: L10n.Scene.Profile.SegmentedControl.media,
self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) queryFilter: .init(onlyMedia: true)
self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:]) )
self.profileAboutViewModel = ProfileAboutViewModel(context: context)
super.init() super.init()
relationshipActionOptionSet // bind me
.compactMap { $0.highPriorityAction(except: []) }
.map { $0 == .none }
.assign(to: \.value, on: isRelationshipActionButtonHidden)
.store(in: &disposeBag)
// bind active authentication
context.authenticationService.activeMastodonAuthenticationBox context.authenticationService.activeMastodonAuthenticationBox
.receive(on: DispatchQueue.main)
.sink { [weak self] authenticationBox in .sink { [weak self] authenticationBox in
guard let self = self else { return } guard let self = self else { return }
guard let authenticationBox = authenticationBox else { self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user
self.domain.value = nil }
self.me = nil .store(in: &disposeBag)
$me
.assign(to: \.me, on: relationshipViewModel)
.store(in: &disposeBag)
// bind user
$user
.map { user -> UserIdentifier? in
guard let user = user else { return nil }
return MastodonUserIdentifier(domain: user.domain, userID: user.id)
}
.assign(to: &$userIdentifier)
$user
.assign(to: \.user, on: relationshipViewModel)
.store(in: &disposeBag)
// bind userIdentifier
$userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier)
// bind bar button items
relationshipViewModel.$optionSet
.sink { [weak self] optionSet in
guard let self = self else { return }
guard let optionSet = optionSet, !optionSet.contains(.none) else {
self.isReplyBarButtonItemHidden = true
self.isMoreMenuBarButtonItemHidden = true
self.isMeBarButtonItemsHidden = true
return return
} }
self.domain.value = authenticationBox.domain
self.me = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user let isMyself = optionSet.contains(.isMyself)
self.isReplyBarButtonItemHidden = isMyself
self.isMoreMenuBarButtonItemHidden = isMyself
self.isMeBarButtonItemsHidden = !isMyself
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -148,198 +160,23 @@ class ProfileViewModel: NSObject {
} catch { } catch {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)") self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)")
} }
} } // end Task
} }
.store(in: &disposeBag) .store(in: &disposeBag)
//
let isBlockingOrBlocked = Publishers.CombineLatest( let isBlockingOrBlocked = Publishers.CombineLatest(
isBlocking, relationshipViewModel.$isBlocking,
isBlockedBy relationshipViewModel.$isBlockingBy
) )
.map { $0 || $1 } .map { $0 || $1 }
.share() .share()
isBlockingOrBlocked
.map { !$0 }
.assign(to: \.value, on: needsPagingEnabled)
.store(in: &disposeBag)
isBlockingOrBlocked
.map { $0 }
.assign(to: \.value, on: needsImageOverlayBlurred)
.store(in: &disposeBag)
setup()
}
}
extension ProfileViewModel {
private func setup() {
Publishers.CombineLatest( Publishers.CombineLatest(
$user, isBlockingOrBlocked,
$me $isEditing
) )
.receive(on: DispatchQueue.main) .map { !$0 && !$1 }
.sink { [weak self] user, me in .assign(to: &$isPagingEnabled)
guard let self = self else { return }
// Update view model attribute
self.update(mastodonUser: user)
self.update(mastodonUser: user, currentMastodonUser: me)
// Setup observer for user
if let mastodonUser = user {
// setup observer
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: mastodonUser)
self.update(mastodonUser: mastodonUser, currentMastodonUser: me)
case .delete:
// TODO:
break
}
}
} else {
self.mastodonUserObserver = nil
}
// Setup observer for user
if let currentMastodonUser = me {
// setup observer
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser)
case .delete:
// TODO:
break
}
}
} else {
self.currentMastodonUserObserver = nil
}
}
.store(in: &disposeBag)
}
private func update(mastodonUser: MastodonUser?) {
self.userID.value = mastodonUser?.id
self.bannerImageURL.value = mastodonUser?.headerImageURL()
self.avatarImageURL.value = mastodonUser?.avatarImageURL()
self.name.value = mastodonUser?.displayNameWithFallback
self.username.value = mastodonUser?.acctWithDomain
self.bioDescription.value = mastodonUser?.note
self.url.value = mastodonUser?.url
self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) }
self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) }
self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false
self.fields.value = mastodonUser?.fields ?? []
self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:]
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
guard let mastodonUser = mastodonUser,
let currentMastodonUser = currentMastodonUser else {
// set relationship
self.relationshipActionOptionSet.value = .none
self.isFollowedBy.value = false
self.isMuting.value = false
self.isBlocking.value = false
self.isBlockedBy.value = false
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
self.isMeBarButtonItemsHidden.value = true
return
}
if mastodonUser == currentMastodonUser {
self.relationshipActionOptionSet.value = [.edit]
// set bar button item state
self.isReplyBarButtonItemHidden.value = true
self.isMoreMenuBarButtonItemHidden.value = true
self.isMeBarButtonItemsHidden.value = false
} else {
// set with follow action default
var relationshipActionSet = RelationshipActionOptionSet([.follow])
if mastodonUser.locked {
relationshipActionSet.insert(.request)
}
if mastodonUser.suspended {
relationshipActionSet.insert(.suspended)
}
let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser)
if isFollowing {
relationshipActionSet.insert(.following)
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description)
let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser)
if isPending {
relationshipActionSet.insert(.pending)
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description)
let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser)
self.isFollowedBy.value = isFollowedBy
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description)
let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser)
if isMuting {
relationshipActionSet.insert(.muting)
}
self.isMuting.value = isMuting
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description)
let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser)
if isBlocking {
relationshipActionSet.insert(.blocking)
}
self.isBlocking.value = isBlocking
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description)
let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser)
if isBlockedBy {
relationshipActionSet.insert(.blocked)
}
self.isBlockedBy.value = isBlockedBy
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description)
self.relationshipActionOptionSet.value = relationshipActionSet
// set bar button item state
self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
self.isMoreMenuBarButtonItemHidden.value = false
self.isMeBarButtonItemsHidden.value = true
}
} }
} }
@ -386,7 +223,7 @@ extension ProfileViewModel {
let authorization = authenticationBox.userAuthorization let authorization = authenticationBox.userAuthorization
let _image: UIImage? = { let _image: UIImage? = {
guard let image = headerProfileInfo.avatarImage else { return nil } guard let image = headerProfileInfo.avatar else { return nil }
guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else {
return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel)
} }

View File

@ -1,90 +0,0 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Pageboy
import Tabman
protocol ProfilePagingViewControllerDelegate: AnyObject {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int)
}
final class ProfilePagingViewController: TabmanViewController {
weak var pagingDelegate: ProfilePagingViewControllerDelegate?
var viewModel: ProfilePagingViewModel!
// MARK: - PageboyViewControllerDelegate
override func pageboyViewController(_ pageboyViewController: PageboyViewController, didCancelScrollToPageAt index: PageboyViewController.PageIndex, returnToPageAt previousIndex: PageboyViewController.PageIndex) {
super.pageboyViewController(pageboyViewController, didCancelScrollToPageAt: index, returnToPageAt: previousIndex)
// Fix the SDK bug for table view get row selected during swipe but cancel paging
guard previousIndex < viewModel.viewControllers.count else { return }
let viewController = viewModel.viewControllers[previousIndex]
if let tableView = viewController.scrollView as? UITableView {
for cell in tableView.visibleCells {
cell.setHighlighted(false, animated: false)
}
}
}
override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) {
super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated)
let viewController = viewModel.viewControllers[index]
(viewController as? StatusTableViewControllerNavigateable)?.overrideNavigationScrollPosition = .top
pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index)
}
// make key commands works
override var canBecomeFirstResponder: Bool {
return true
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfilePagingViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dataSource = viewModel
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
}
// workaround to fix tab man responder chain issue
extension ProfilePagingViewController {
override var keyCommands: [UIKeyCommand]? {
return currentViewController?.keyCommands
}
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender)
}
}

View File

@ -1,82 +0,0 @@
//
// ProfilePagingViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Pageboy
import Tabman
import MastodonAsset
import MastodonLocalization
final class ProfilePagingViewModel: NSObject {
let postUserTimelineViewController = UserTimelineViewController()
let repliesUserTimelineViewController = UserTimelineViewController()
let mediaUserTimelineViewController = UserTimelineViewController()
let profileAboutViewController = ProfileAboutViewController()
init(
postsUserTimelineViewModel: UserTimelineViewModel,
repliesUserTimelineViewModel: UserTimelineViewModel,
mediaUserTimelineViewModel: UserTimelineViewModel,
profileAboutViewModel: ProfileAboutViewModel
) {
postUserTimelineViewController.viewModel = postsUserTimelineViewModel
repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel
mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel
profileAboutViewController.viewModel = profileAboutViewModel
super.init()
}
var viewControllers: [ScrollViewContainer] {
return [
postUserTimelineViewController,
repliesUserTimelineViewController,
mediaUserTimelineViewController,
profileAboutViewController,
]
}
let barItems: [TMBarItemable] = {
let items = [
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about),
]
return items
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - PageboyViewControllerDataSource
extension ProfilePagingViewModel: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
return viewControllers[index]
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
return .first
}
}
// MARK: - TMBarDataSource
extension ProfilePagingViewModel: TMBarDataSource {
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
return barItems[index]
}
}

View File

@ -1,39 +0,0 @@
//
// ProfileSegmentedViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
final class ProfileSegmentedViewController: UIViewController {
let pagingViewController = ProfilePagingViewController()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfileSegmentedViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
addChild(pagingViewController)
pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pagingViewController.view)
pagingViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor),
])
}
}

View File

@ -11,6 +11,8 @@ import AVKit
import Combine import Combine
import CoreDataStack import CoreDataStack
import GameplayKit import GameplayKit
import TabBarPager
import XLPagerTabStrip
final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -143,7 +145,14 @@ extension UserTimelineViewController: UITableViewDelegate, AutoGenerateTableView
// MARK: - CustomScrollViewContainerController // MARK: - CustomScrollViewContainerController
extension UserTimelineViewController: ScrollViewContainer { extension UserTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView? { return tableView } var scrollView: UIScrollView { return tableView }
}
// MARK: - TabBarPage
extension UserTimelineViewController: TabBarPage {
var pageScrollView: UIScrollView {
scrollView
}
} }
// MARK: - StatusTableViewCellDelegate // MARK: - StatusTableViewCellDelegate
@ -165,3 +174,10 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable {
statusKeyCommandHandler(sender) statusKeyCommandHandler(sender)
} }
} }
// MARK: - IndicatorInfoProvider
extension UserTimelineViewController: IndicatorInfoProvider {
func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
return IndicatorInfo(title: viewModel.title)
}
}

View File

@ -30,11 +30,8 @@ extension UserTimelineViewModel {
snapshot.appendSections([.main]) snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot) diffableDataSource?.apply(snapshot)
// trigger user timeline loading // trigger timeline reloading
Publishers.CombineLatest( $userIdentifier
$domain.removeDuplicates(),
$userID.removeDuplicates()
)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
@ -43,9 +40,9 @@ extension UserTimelineViewModel {
.store(in: &disposeBag) .store(in: &disposeBag)
let needsTimelineHidden = Publishers.CombineLatest3( let needsTimelineHidden = Publishers.CombineLatest3(
isBlocking, $isBlocking,
isBlockedBy, $isBlockedBy,
isSuspended $isSuspended
).map { $0 || $1 || $2 } ).map { $0 || $1 || $2 }
Publishers.CombineLatest( Publishers.CombineLatest(

View File

@ -50,7 +50,7 @@ extension UserTimelineViewModel.State {
guard let viewModel = viewModel else { return false } guard let viewModel = viewModel else { return false }
switch stateClass { switch stateClass {
case is Reloading.Type: case is Reloading.Type:
return viewModel.userID != nil return viewModel.userIdentifier != nil
default: default:
return false return false
} }
@ -132,7 +132,7 @@ extension UserTimelineViewModel.State {
let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last
guard let userID = viewModel.userID, !userID.isEmpty else { guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else {
stateMachine.enter(Fail.self) stateMachine.enter(Fail.self)
return return
} }
@ -194,7 +194,7 @@ extension UserTimelineViewModel.State {
guard let viewModel = viewModel, let _ = stateMachine else { return } guard let viewModel = viewModel, let _ = stateMachine else { return }
// trigger data source update. otherwise, spinner always display // trigger data source update. otherwise, spinner always display
viewModel.isSuspended.value = viewModel.isSuspended.value viewModel.isSuspended = viewModel.isSuspended
// remove bottom loader // remove bottom loader
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return }

View File

@ -19,17 +19,18 @@ final class UserTimelineViewModel {
// input // input
let context: AppContext let context: AppContext
@Published var domain: String? let title: String
@Published var userID: String?
@Published var queryFilter: QueryFilter
let statusFetchedResultsController: StatusFetchedResultsController let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel() let listBatchFetchViewModel = ListBatchFetchViewModel()
@Published var userIdentifier: UserIdentifier?
@Published var queryFilter: QueryFilter
let isBlocking = CurrentValueSubject<Bool, Never>(false) @Published var isBlocking = false
let isBlockedBy = CurrentValueSubject<Bool, Never>(false) @Published var isBlockedBy = false
let isSuspended = CurrentValueSubject<Bool, Never>(false) @Published var isSuspended = false
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
var dataSourceDidUpdate = PassthroughSubject<Void, Never>() // let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
// var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
// output // output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>? var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
@ -48,30 +49,27 @@ final class UserTimelineViewModel {
init( init(
context: AppContext, context: AppContext,
domain: String?, title: String,
userID: String?,
queryFilter: QueryFilter queryFilter: QueryFilter
) { ) {
self.context = context self.context = context
self.title = title
self.statusFetchedResultsController = StatusFetchedResultsController( self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext, managedObjectContext: context.managedObjectContext,
domain: domain, domain: nil,
additionalTweetPredicate: Status.notDeleted() additionalTweetPredicate: nil
) )
self.domain = domain
self.userID = userID
self.queryFilter = queryFilter self.queryFilter = queryFilter
// super.init() // super.init()
$domain context.authenticationService.activeMastodonAuthenticationBox
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain) .assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag) .store(in: &disposeBag)
} }
deinit { deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
} }
} }
@ -92,5 +90,4 @@ extension UserTimelineViewModel {
self.onlyMedia = onlyMedia self.onlyMedia = onlyMedia
} }
} }
} }

View File

@ -7,12 +7,12 @@
import UIKit import UIKit
// Make status bar style adaptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
class AdaptiveStatusBarStyleNavigationController: UINavigationController { class AdaptiveStatusBarStyleNavigationController: UINavigationController {
private lazy var fullWidthBackGestureRecognizer = UIPanGestureRecognizer() private lazy var fullWidthBackGestureRecognizer = UIPanGestureRecognizer()
// Make status bar style adaptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
override var childForStatusBarStyle: UIViewController? { override var childForStatusBarStyle: UIViewController? {
visibleViewController visibleViewController
} }

View File

@ -0,0 +1,28 @@
//
// UserIdentifier.swift
//
//
// Created by MainasuK on 2022-5-13.
//
import Foundation
import MastodonSDK
public protocol UserIdentifier {
var domain: String { get }
var userID: Mastodon.Entity.Account.ID { get }
}
public struct MastodonUserIdentifier: UserIdentifier {
public let domain: String
public var userID: Mastodon.Entity.Account.ID
public init(
domain: String,
userID: Mastodon.Entity.Account.ID
) {
self.domain = domain
self.userID = userID
}
}

View File

@ -1,14 +0,0 @@
//
// UserIdentifier.swift
//
//
// Created by MainasuK on 2022-1-12.
//
import Foundation
import MastodonSDK
public protocol UserIdentifier {
var domain: String { get }
var userID: Mastodon.Entity.Account.ID { get }
}

View File

@ -84,7 +84,7 @@ public struct RelationshipActionOptionSet: OptionSet {
case .pending: return L10n.Common.Controls.Friendship.pending case .pending: return L10n.Common.Controls.Friendship.pending
case .following: return L10n.Common.Controls.Friendship.following case .following: return L10n.Common.Controls.Friendship.following
case .muting: return L10n.Common.Controls.Friendship.muted case .muting: return L10n.Common.Controls.Friendship.muted
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user (deprecated)
case .blocking: return L10n.Common.Controls.Friendship.blocked case .blocking: return L10n.Common.Controls.Friendship.blocked
case .suspended: return L10n.Common.Controls.Friendship.follow case .suspended: return L10n.Common.Controls.Friendship.follow
case .edit: return L10n.Common.Controls.Friendship.editInfo case .edit: return L10n.Common.Controls.Friendship.editInfo
@ -116,6 +116,7 @@ public final class RelationshipViewModel {
@Published public var isMuting = false @Published public var isMuting = false
@Published public var isBlocking = false @Published public var isBlocking = false
@Published public var isBlockingBy = false @Published public var isBlockingBy = false
@Published public var isSuspended = false
public init() { public init() {
Publishers.CombineLatest3( Publishers.CombineLatest3(
@ -182,7 +183,7 @@ extension RelationshipViewModel {
self.isMuting = optionSet.contains(.muting) self.isMuting = optionSet.contains(.muting)
self.isBlockingBy = optionSet.contains(.blockingBy) self.isBlockingBy = optionSet.contains(.blockingBy)
self.isBlocking = optionSet.contains(.blocking) self.isBlocking = optionSet.contains(.blocking)
self.isSuspended = optionSet.contains(.suspended)
self.optionSet = optionSet self.optionSet = optionSet
} }
@ -203,7 +204,7 @@ extension RelationshipViewModel {
public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet { public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet {
let isMyself = user.id == me.id && user.domain == me.domain let isMyself = user.id == me.id && user.domain == me.domain
guard !isMyself else { guard !isMyself else {
return [.isMyself] return [.isMyself, .edit]
} }
let isProtected = user.locked let isProtected = user.locked
@ -248,6 +249,10 @@ extension RelationshipViewModel {
optionSet.insert(.blocking) optionSet.insert(.blocking)
} }
if user.suspended {
optionSet.insert(.suspended)
}
return optionSet return optionSet
} }
} }

View File

@ -8,6 +8,7 @@ target 'Mastodon' do
# UI # UI
pod 'UITextField+Shake', '~> 1.2' pod 'UITextField+Shake', '~> 1.2'
pod 'XLPagerTabStrip', '~> 9.0.0'
# misc # misc
pod 'SwiftGen', '~> 6.4.0' pod 'SwiftGen', '~> 6.4.0'

View File

@ -8,6 +8,7 @@ PODS:
- Sourcery/CLI-Only (1.6.1) - Sourcery/CLI-Only (1.6.1)
- SwiftGen (6.4.0) - SwiftGen (6.4.0)
- "UITextField+Shake (1.2.1)" - "UITextField+Shake (1.2.1)"
- XLPagerTabStrip (9.0.0)
DEPENDENCIES: DEPENDENCIES:
- DateToolsSwift (~> 5.0.0) - DateToolsSwift (~> 5.0.0)
@ -17,6 +18,7 @@ DEPENDENCIES:
- Sourcery (~> 1.6.1) - Sourcery (~> 1.6.1)
- SwiftGen (~> 6.4.0) - SwiftGen (~> 6.4.0)
- "UITextField+Shake (~> 1.2)" - "UITextField+Shake (~> 1.2)"
- XLPagerTabStrip (~> 9.0.0)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@ -26,6 +28,7 @@ SPEC REPOS:
- Sourcery - Sourcery
- SwiftGen - SwiftGen
- "UITextField+Shake" - "UITextField+Shake"
- XLPagerTabStrip
EXTERNAL SOURCES: EXTERNAL SOURCES:
Keys: Keys:
@ -39,7 +42,8 @@ SPEC CHECKSUMS:
Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
XLPagerTabStrip: 61c57fd61f611ee5f01ff1495ad6fbee8bf496c5
PODFILE CHECKSUM: 335d0ca70493d4c280d0f8fd7f26fe9be6a4e289 PODFILE CHECKSUM: 1ac960a2c981ef98f7c24a3bba57bdabc1f66103
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3