fix: refactor the profile UI to fix internal AutoLayout crash issue. resolve #440

This commit is contained in:
CMK 2022-05-26 23:19:47 +08:00
parent 503fcfab2a
commit ad63c512df
23 changed files with 1553 additions and 1764 deletions

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 */; };
@ -508,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 */; };
@ -889,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>"; };
@ -1286,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>"; };
@ -3006,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 */,
@ -3106,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 = (
@ -3150,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;
@ -4041,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 */,
@ -4398,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 */,
@ -4406,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 */,

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

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

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

@ -25,7 +25,8 @@ 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 {
case .editField: return true case .editField: return true
@ -42,22 +43,25 @@ 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)
.sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in .sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in
guard let self = self else { return } guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return } guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>() var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
@ -69,17 +73,17 @@ extension ProfileAboutViewModel {
return ProfileFieldItem.field(field: field) return ProfileFieldItem.field(field: field)
} }
} }
if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount { if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount {
items.append(.addEntry) items.append(.addEntry)
} }
if !isEditing, items.isEmpty { if !isEditing, items.isEmpty {
items.append(.noResult) items.append(.noResult)
} }
snapshot.appendItems(items, toSection: .main) snapshot.appendItems(items, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
} }
.store(in: &disposeBag) .store(in: &disposeBag)

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
@ -18,19 +19,27 @@ import MastodonLocalization
import TabBarPager 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
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileHeaderViewModel!
weak var delegate: ProfileHeaderViewControllerDelegate? weak var delegate: ProfileHeaderViewControllerDelegate?
weak var headerDelegate: TabBarPagerHeaderDelegate? weak var headerDelegate: TabBarPagerHeaderDelegate?
var viewModel: ProfileHeaderViewModel! let mediaPreviewTransitionController = MediaPreviewTransitionController()
let titleView: DoubleTitleLabelNavigationBarTitleView = { let titleView: DoubleTitleLabelNavigationBarTitleView = {
let titleView = DoubleTitleLabelNavigationBarTitleView() let titleView = DoubleTitleLabelNavigationBarTitleView()
@ -44,39 +53,8 @@ final class ProfileHeaderViewController: UIViewController {
}() }()
let profileHeaderView = ProfileHeaderView() let profileHeaderView = ProfileHeaderView()
// let buttonBar: TMBar.ButtonBar = {
// let buttonBar = TMBar.ButtonBar()
// buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
// buttonBar.backgroundView.style = .clear
// buttonBar.layout.contentInset = .zero
// return buttonBar
// }()
// func customizeButtonBarAppearance() { // private var isBannerPinned = false
// // 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
@ -104,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)
} }
} }
@ -114,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
@ -125,6 +103,7 @@ 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([
@ -133,130 +112,64 @@ extension ProfileHeaderViewController {
profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor), 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)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.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()
} }
@ -264,19 +177,7 @@ extension ProfileHeaderViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
switch UIApplication.shared.applicationState { headerDelegate?.viewLayoutDidUpdate(self)
case .active:
headerDelegate?.viewLayoutDidUpdate(self)
setupBottomShadow()
default:
break
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// customizeButtonBarAppearance()
} }
} }
@ -328,80 +229,28 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset containerSafeAreaInset = inset
} }
func setupBottomShadow() { func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
guard viewModel.needsSetupBottomShadow.value else { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
view.layer.shadowColor = nil
view.layer.shadowRadius = 0 // set title view offset
return let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
} let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
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) let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
} let transformY = max(0, titleViewContentOffset)
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
private func updateHeaderBottomShadow(progress: CGFloat) { viewModel.isTitleViewDisplaying = transformY < titleView.containerView.frame.height
let alpha = min(max(0, 10 * progress - 9), 1) viewModel.isTitleViewContentOffsetSet = true
if bottomShadowAlpha != alpha {
bottomShadowAlpha = alpha if progress > 0, throttle > 0 {
view.setNeedsLayout() // y = 1 - (x/t)
// give: x = 0, y = 1
// x = t, y = 0
let alpha = 1 - progress/throttle
setProfileAvatar(alpha: alpha)
} else {
setProfileAvatar(alpha: 1)
} }
} }
// 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)
// 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
// let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
// let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
// let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
// let transformY = max(0, titleViewContentOffset)
// titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
// viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height
//
// if viewModel.viewDidAppear.value {
// viewModel.isTitleViewContentOffsetSet.value = true
// }
//
// // set avatar fade
// 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)
// } else {
// setProfileAvatar(alpha: 1)
// }
// }
private func setProfileAvatar(alpha: CGFloat) { private func setProfileAvatar(alpha: CGFloat) {
profileHeaderView.avatarImageViewBackgroundView.alpha = alpha profileHeaderView.avatarImageViewBackgroundView.alpha = alpha
@ -411,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? {
@ -419,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:
@ -491,7 +439,7 @@ 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)
} }
} }

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,39 +23,44 @@ final class ProfileHeaderViewModel {
// input // input
let context: AppContext let context: AppContext
@Published var user: MastodonUser?
@Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none
@Published var isEditing = false @Published var isEditing = false
@Published var accountForEdit: Mastodon.Entity.Account? @Published var isUpdating = false
@Published var emojiMeta: MastodonContent.Emojis = [:]
let viewDidAppear = CurrentValueSubject<Bool, Never>(false) @Published var accountForEdit: Mastodon.Entity.Account?
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false) // let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false)
let isTitleViewContentOffsetSet = 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( $accountForEdit
$isEditing.removeDuplicates(), // only trigger when value toggle .receive(on: DispatchQueue.main)
$accountForEdit .sink { [weak self] account in
) guard let self = self else { return }
.receive(on: DispatchQueue.main) guard let account = account else { return }
.sink { [weak self] isEditing, account in // avatar
guard let self = self else { return } self.profileInfo.avatar = nil
guard isEditing else { return } self.profileInfoEditing.avatar = nil
// setup editing value when toggle to editing // name
self.editProfileInfo.name = self.displayProfileInfo.name // set to name let name = account.displayNameWithFallback
self.editProfileInfo.avatarImage = nil // set to empty self.profileInfo.name = name
self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note) self.profileInfoEditing.name = name
self.editProfileInfoDidInitialized.send() // bio
} let note = ProfileHeaderViewModel.normalize(note: account.note)
.store(in: &disposeBag) self.profileInfo.note = note
self.profileInfoEditing.note = note
}
.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,8 +38,16 @@ 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 = {
let imageView = UIImageView() let imageView = 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
@ -310,7 +328,7 @@ extension ProfileHeaderView {
layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor), layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor),
container.bottomAnchor.constraint(equalTo: bottomAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
// dashboardContainer: H - [ padding | statusDashboardView ] // dashboardContainer: H - [ padding | statusDashboardView ]
let dashboardContainer = UIStackView() let dashboardContainer = UIStackView()
dashboardContainer.axis = .horizontal dashboardContainer.axis = .horizontal
@ -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

@ -18,6 +18,9 @@ final class ProfilePagingViewModel: NSObject {
let mediaUserTimelineViewController = UserTimelineViewController() let mediaUserTimelineViewController = UserTimelineViewController()
let profileAboutViewController = ProfileAboutViewController() let profileAboutViewController = ProfileAboutViewController()
// input
@Published var needsSetupBottomShadow = true
init( init(
postsUserTimelineViewModel: UserTimelineViewModel, postsUserTimelineViewModel: UserTimelineViewModel,
repliesUserTimelineViewModel: UserTimelineViewModel, repliesUserTimelineViewModel: UserTimelineViewModel,
@ -40,42 +43,8 @@ final class ProfilePagingViewModel: NSObject {
] ]
} }
// 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 { deinit {
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)
} }
} }
//// 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]
// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -41,74 +41,38 @@ class ProfileViewModel: NSObject {
@Published var isEditing = false @Published var isEditing = false
@Published var isUpdating = false @Published var isUpdating = false
@Published var accountForEdit: Mastodon.Entity.Account?
// output // output
let relationshipViewModel = RelationshipViewModel()
@Published var userIdentifier: UserIdentifier? = nil @Published var userIdentifier: UserIdentifier? = nil
@Published var isRelationshipActionButtonHidden: Bool = true
@Published var isReplyBarButtonItemHidden: Bool = true
@Published var isMoreMenuBarButtonItemHidden: Bool = true
@Published var isMeBarButtonItemsHidden: Bool = true
@Published var isPagingEnabled = true
// let domain: CurrentValueSubject<String?, Never> // @Published var protected: Bool? = nil
// let userID: CurrentValueSubject<UserID?, Never> // let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
// 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
let accountForEdit = CurrentValueSubject<Mastodon.Entity.Account?, Never>(nil)
// let protected: CurrentValueSubject<Bool?, Never>
// let suspended: CurrentValueSubject<Bool, Never>
//
// 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.userID = CurrentValueSubject(mastodonUser?.id)
// self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
// self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
// self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
// self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
// self.bioDescription = CurrentValueSubject(mastodonUser?.note)
// self.url = CurrentValueSubject(mastodonUser?.url)
// self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) })
// self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) })
// self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) })
// self.protected = CurrentValueSubject(mastodonUser?.locked)
// self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
// self.fields = CurrentValueSubject(mastodonUser?.fields ?? [])
// self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:])
self.postsUserTimelineViewModel = UserTimelineViewModel( self.postsUserTimelineViewModel = UserTimelineViewModel(
context: context, context: context,
title: L10n.Scene.Profile.SegmentedControl.posts,
queryFilter: .init(excludeReplies: true) queryFilter: .init(excludeReplies: true)
) )
self.repliesUserTimelineViewModel = UserTimelineViewModel( self.repliesUserTimelineViewModel = UserTimelineViewModel(
context: context, context: context,
title: L10n.Scene.Profile.SegmentedControl.postsAndReplies,
queryFilter: .init(excludeReplies: true) queryFilter: .init(excludeReplies: true)
) )
self.mediaUserTimelineViewModel = UserTimelineViewModel( self.mediaUserTimelineViewModel = UserTimelineViewModel(
context: context, context: context,
title: L10n.Scene.Profile.SegmentedControl.media,
queryFilter: .init(onlyMedia: true) queryFilter: .init(onlyMedia: true)
) )
self.profileAboutViewModel = ProfileAboutViewModel(context: context) self.profileAboutViewModel = ProfileAboutViewModel(context: context)
@ -122,6 +86,9 @@ class ProfileViewModel: NSObject {
self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user
} }
.store(in: &disposeBag) .store(in: &disposeBag)
$me
.assign(to: \.me, on: relationshipViewModel)
.store(in: &disposeBag)
// bind user // bind user
$user $user
@ -130,250 +97,88 @@ class ProfileViewModel: NSObject {
return MastodonUserIdentifier(domain: user.domain, userID: user.id) return MastodonUserIdentifier(domain: user.domain, userID: user.id)
} }
.assign(to: &$userIdentifier) .assign(to: &$userIdentifier)
$user
.assign(to: \.user, on: relationshipViewModel)
.store(in: &disposeBag)
// bind userIdentifier
$userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier) $userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier) $userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier) $userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier)
// $userIdentifier.assign(to: &profileAboutViewModel.$userIdentifier)
// relationshipActionOptionSet // bind bar button items
// .compactMap { $0.highPriorityAction(except: []) } relationshipViewModel.$optionSet
// .map { $0 == .none } .sink { [weak self] optionSet in
// .assign(to: \.value, on: isRelationshipActionButtonHidden) guard let self = self else { return }
// .store(in: &disposeBag) guard let optionSet = optionSet, !optionSet.contains(.none) else {
// self.isReplyBarButtonItemHidden = true
self.isMoreMenuBarButtonItemHidden = true
self.isMeBarButtonItemsHidden = true
return
}
let isMyself = optionSet.contains(.isMyself)
self.isReplyBarButtonItemHidden = isMyself
self.isMoreMenuBarButtonItemHidden = isMyself
self.isMeBarButtonItemsHidden = !isMyself
}
.store(in: &disposeBag)
// // query relationship
// // query relationship let userRecord = $user.map { user -> ManagedObjectRecord<MastodonUser>? in
// let userRecord = $user.map { user -> ManagedObjectRecord<MastodonUser>? in user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
// user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) } }
// } let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
// let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
//
// // observe friendship
// Publishers.CombineLatest3(
// userRecord,
// context.authenticationService.activeMastodonAuthenticationBox,
// pendingRetryPublisher
// )
// .sink { [weak self] userRecord, authenticationBox, _ in
// guard let self = self else { return }
// guard let userRecord = userRecord,
// let authenticationBox = authenticationBox
// else { return }
// Task {
// do {
// let response = try await self.updateRelationship(
// record: userRecord,
// authenticationBox: authenticationBox
// )
// // there are seconds delay after request follow before requested -> following. Query again when needs
// guard let relationship = response.value.first else { return }
// if relationship.requested == true {
// let delay = pendingRetryPublisher.value
// DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
// guard let _ = self else { return }
// pendingRetryPublisher.value = min(2 * delay, 60)
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
// }
// }
// } 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)")
// }
// }
// }
// .store(in: &disposeBag)
//
// let isBlockingOrBlocked = Publishers.CombineLatest(
// isBlocking,
// isBlockedBy
// )
// .map { $0 || $1 }
// .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 { // observe friendship
private func setup() { Publishers.CombineLatest3(
Publishers.CombineLatest( userRecord,
$user, context.authenticationService.activeMastodonAuthenticationBox,
$me pendingRetryPublisher
) )
.receive(on: DispatchQueue.main) .sink { [weak self] userRecord, authenticationBox, _ in
.sink { [weak self] user, me in
guard let self = self else { return } guard let self = self else { return }
// Update view model attribute guard let userRecord = userRecord,
self.update(mastodonUser: user) let authenticationBox = authenticationBox
self.update(mastodonUser: user, currentMastodonUser: me) else { return }
Task {
// Setup observer for user do {
if let mastodonUser = user { let response = try await self.updateRelationship(
// setup observer record: userRecord,
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) authenticationBox: authenticationBox
.sink { completion in )
switch completion { // there are seconds delay after request follow before requested -> following. Query again when needs
case .failure(let error): guard let relationship = response.value.first else { return }
assertionFailure(error.localizedDescription) if relationship.requested == true {
case .finished: let delay = pendingRetryPublisher.value
assertionFailure() DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
} guard let _ = self else { return }
} receiveValue: { [weak self] change in pendingRetryPublisher.value = min(2 * delay, 60)
guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
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
} }
} }
} catch {
} else { 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.mastodonUserObserver = nil }
} } // end Task
// 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) .store(in: &disposeBag)
//
let isBlockingOrBlocked = Publishers.CombineLatest(
relationshipViewModel.$isBlocking,
relationshipViewModel.$isBlockingBy
)
.map { $0 || $1 }
.share()
Publishers.CombineLatest(
isBlockingOrBlocked,
$isEditing
)
.map { !$0 && !$1 }
.assign(to: &$isPagingEnabled)
} }
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
// }
}
} }
extension ProfileViewModel { extension ProfileViewModel {
@ -418,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,92 +0,0 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import XLPagerTabStrip
import TabBarPager
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 viewModel: ProfilePagingViewModel!
// 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() {
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 currentPage?.keyCommands
// }
//
// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender)
//
// }
//
// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender)
// }
}

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

@ -178,6 +178,6 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable {
// MARK: - IndicatorInfoProvider // MARK: - IndicatorInfoProvider
extension UserTimelineViewController: IndicatorInfoProvider { extension UserTimelineViewController: IndicatorInfoProvider {
func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
return IndicatorInfo(title: "Hello") return IndicatorInfo(title: viewModel.title)
} }
} }

View File

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

@ -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,16 +19,18 @@ final class UserTimelineViewModel {
// input // input
let context: AppContext let context: AppContext
@Published var userIdentifier: UserIdentifier? let title: 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>?
@ -47,9 +49,11 @@ final class UserTimelineViewModel {
init( init(
context: AppContext, context: AppContext,
title: 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: nil, domain: nil,

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

@ -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,8 +183,8 @@ 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
@ -247,6 +248,10 @@ extension RelationshipViewModel {
if isBlocking { if isBlocking {
optionSet.insert(.blocking) optionSet.insert(.blocking)
} }
if user.suspended {
optionSet.insert(.suspended)
}
return optionSet return optionSet
} }