From 4367e8eabaf0fd8db87ea21e83dbbf9fc09a9175 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 Oct 2022 18:31:40 +0800 Subject: [PATCH] feat: [WP] restore the content compose via SwiftUI and support expandable reply view for compose scene --- .../Scene/Compose/ComposeViewController.swift | 175 +++++------------- .../CoreDataStack/MastodonUser.swift | 29 ++- .../ComposeContent/ComposeContentView.swift | 100 ---------- .../ComposeContentViewController.swift | 93 ++++++++++ .../ComposeContentViewModel+DataSource.swift | 39 ++-- .../ComposeContentViewModel.swift | 52 +++++- ...wift => ComposeContentTableViewCell.swift} | 45 +++-- .../View/ComposeContentView.swift | 97 ++++++++++ .../SwiftUI/MetaLabelRepresentable.swift | 47 +++++ .../SwiftUI/MetaTextViewRepresentable.swift | 82 ++++++++ .../View/Utility/ViewLayoutFrame.swift | 57 ++++++ 11 files changed, 548 insertions(+), 268 deletions(-) delete mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentView.swift rename MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/{ComposeStatusContentTableViewCell.swift => ComposeContentTableViewCell.swift} (89%) create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift create mode 100644 MastodonSDK/Sources/MastodonUI/SwiftUI/MetaLabelRepresentable.swift create mode 100644 MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift create mode 100644 MastodonSDK/Sources/MastodonUI/View/Utility/ViewLayoutFrame.swift diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 1fd3b0670..1d847f040 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -31,7 +31,11 @@ final class ComposeViewController: UIViewController, NeedsDependency { let logger = Logger(subsystem: "ComposeViewController", category: "logic") lazy var composeContentViewModel: ComposeContentViewModel = { - return ComposeContentViewModel(context: context, kind: viewModel.kind) + return ComposeContentViewModel( + context: context, + authContext: viewModel.authContext, + kind: viewModel.kind + ) }() private(set) lazy var composeContentViewController: ComposeContentViewController = { let composeContentViewController = ComposeContentViewController() @@ -39,20 +43,20 @@ final class ComposeViewController: UIViewController, NeedsDependency { return composeContentViewController }() -// private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) -// let characterCountLabel: UILabel = { -// let label = UILabel() -// label.font = .systemFont(ofSize: 15, weight: .regular) -// label.text = "500" -// label.textColor = Asset.Colors.Label.secondary.color -// label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500) -// return label -// }() -// private(set) lazy var characterCountBarButtonItem: UIBarButtonItem = { -// let barButtonItem = UIBarButtonItem(customView: characterCountLabel) -// return barButtonItem -// }() -// + private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + let characterCountLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.text = "500" + label.textColor = Asset.Colors.Label.secondary.color + label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500) + return label + }() + private(set) lazy var characterCountBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: characterCountLabel) + return barButtonItem + }() + // let publishButton: UIButton = { // let button = RoundedEdgesButton(type: .custom) // button.cornerRadius = 10 @@ -83,23 +87,6 @@ final class ComposeViewController: UIViewController, NeedsDependency { // publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) // publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) // } - -// let scrollView: UIScrollView = { -// let scrollView = UIScrollView() -// scrollView.alwaysBounceVertical = true -// return scrollView -// }() - -// let tableView: ComposeTableView = { -// let tableView = ComposeTableView() -// tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self)) -// tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) -// tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) -// tableView.alwaysBounceVertical = true -// tableView.separatorStyle = .none -// tableView.tableFooterView = UIView() -// return tableView -// }() // var systemKeyboardHeight: CGFloat = .zero { // didSet { @@ -177,6 +164,22 @@ extension ComposeViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.leftBarButtonItem = cancelBarButtonItem + // navigationItem.rightBarButtonItem = publishBarButtonItem + // viewModel.traitCollectionDidChangePublisher + // .receive(on: DispatchQueue.main) + // .sink { [weak self] _ in + // guard let self = self else { return } + // guard self.traitCollection.userInterfaceIdiom == .pad else { return } + // var items = [self.publishBarButtonItem] + // if self.traitCollection.horizontalSizeClass == .regular { + // items.append(self.characterCountBarButtonItem) + // } + // self.navigationItem.rightBarButtonItems = items + // } + // .store(in: &disposeBag) + // publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) + addChild(composeContentViewController) composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(composeContentViewController.view) @@ -212,21 +215,6 @@ extension ComposeViewController { // self.setupBackgroundColor(theme: theme) // } // .store(in: &disposeBag) -// navigationItem.leftBarButtonItem = cancelBarButtonItem -// navigationItem.rightBarButtonItem = publishBarButtonItem -// viewModel.traitCollectionDidChangePublisher -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let self = self else { return } -// guard self.traitCollection.userInterfaceIdiom == .pad else { return } -// var items = [self.publishBarButtonItem] -// if self.traitCollection.horizontalSizeClass == .regular { -// items.append(self.characterCountBarButtonItem) -// } -// self.navigationItem.rightBarButtonItems = items -// } -// .store(in: &disposeBag) -// publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) // // // scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -533,26 +521,6 @@ extension ComposeViewController { // }) // .store(in: &disposeBag) // -// // setup snap behavior -// Publishers.CombineLatest( -// viewModel.$repliedToCellFrame, -// viewModel.$collectionViewState -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] repliedToCellFrame, collectionViewState in -// guard let self = self else { return } -// guard repliedToCellFrame != .zero else { return } -// switch collectionViewState { -// case .fold: -// self.tableView.contentInset.top = -repliedToCellFrame.height -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: set contentInset.top: -%s", ((#file as NSString).lastPathComponent), #line, #function, repliedToCellFrame.height.description) -// -// case .expand: -// self.tableView.contentInset.top = 0 -// } -// } -// .store(in: &disposeBag) -// // configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar.value) // Publishers.CombineLatest( // keyboardHasShortcutBar, @@ -746,17 +714,17 @@ extension ComposeViewController { // //} // -//extension ComposeViewController { -// -// @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +extension ComposeViewController { + + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") // guard viewModel.shouldDismiss else { // showDismissConfirmAlertController() // return // } -// dismiss(animated: true, completion: nil) -// } -// + dismiss(animated: true, completion: nil) + } + // @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) // do { @@ -779,9 +747,9 @@ extension ComposeViewController { // // dismiss(animated: true, completion: nil) // } -// -//} -// + +} + //// MARK: - MetaTextDelegate //extension ComposeViewController: MetaTextDelegate { // func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { @@ -1020,58 +988,7 @@ extension ComposeViewController { // } // //} -// -//// MARK: - UIScrollViewDelegate -//extension ComposeViewController { -//// func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { -//// guard scrollView === tableView else { return } -//// -//// let repliedToCellFrame = viewModel.repliedToCellFrame -//// guard repliedToCellFrame != .zero else { return } -//// -//// // try to find some patterns: -//// // print(""" -//// // repliedToCellFrame: \(viewModel.repliedToCellFrame.value.height) -//// // scrollView.contentOffset.y: \(scrollView.contentOffset.y) -//// // scrollView.contentSize.height: \(scrollView.contentSize.height) -//// // scrollView.frame: \(scrollView.frame) -//// // scrollView.adjustedContentInset.top: \(scrollView.adjustedContentInset.top) -//// // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) -//// // """) -//// -//// switch viewModel.collectionViewState { -//// case .fold: -//// os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function) -//// guard velocity.y < 0 else { return } -//// let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top -//// if offsetY < -44 { -//// tableView.contentInset.top = 0 -//// targetContentOffset.pointee = CGPoint(x: 0, y: -scrollView.adjustedContentInset.top) -//// viewModel.collectionViewState = .expand -//// } -//// -//// case .expand: -//// os_log("%{public}s[%{public}ld], %{public}s: expand", ((#file as NSString).lastPathComponent), #line, #function) -//// guard velocity.y > 0 else { return } -//// // check if top across -//// let topOffset = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) - repliedToCellFrame.height -//// -//// // check if bottom bounce -//// let bottomOffsetY = scrollView.contentOffset.y + (scrollView.frame.height - scrollView.adjustedContentInset.bottom) -//// let bottomOffset = bottomOffsetY - scrollView.contentSize.height -//// -//// if topOffset > 44 { -//// // do not interrupt user scrolling -//// viewModel.collectionViewState = .fold -//// } else if bottomOffset > 44 { -//// tableView.contentInset.top = -repliedToCellFrame.height -//// targetContentOffset.pointee = CGPoint(x: 0, y: -repliedToCellFrame.height) -//// viewModel.collectionViewState = .fold -//// } -//// } -//// } -//} -// + //// MARK: - UITableViewDelegate //extension ComposeViewController: UITableViewDelegate { } // diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift index 02a983680..6d952726c 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift @@ -8,6 +8,7 @@ import Foundation import CoreDataStack import MastodonSDK +import MastodonMeta extension MastodonUser { @@ -57,7 +58,7 @@ extension MastodonUser { } extension MastodonUser { - + public var profileURL: URL { if let urlString = self.url, let url = URL(string: urlString) { @@ -72,4 +73,30 @@ extension MastodonUser { items.append(profileURL) return items } + +} + +extension MastodonUser { + public var nameMetaContent: MastodonMetaContent? { + do { + let content = MastodonContent(content: displayNameWithFallback, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure() + return nil + } + } + + public var bioMetaContent: MastodonMetaContent? { + guard let note = note else { return nil } + do { + let content = MastodonContent(content: note, emojis: emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + assertionFailure() + return nil + } + } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentView.swift deleted file mode 100644 index 886634d7e..000000000 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// ComposeContentView.swift -// -// -// Created by MainasuK on 22/9/30. -// - -import SwiftUI - -public struct ComposeContentView: View { - - @ObservedObject var viewModel: ComposeContentViewModel - - @State var contentOffsetDelta: CGFloat = .zero - - public var body: some View { - ScrollView { - VStack(spacing: .zero) { - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("scrollView")).origin - ) - }.frame(width: 0, height: 0) - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - print("contentOffset: \(offset)") - } - VStack { - Text("Reply") - } - .frame(height: 100) - .frame(maxWidth: .infinity) - .background(Color.blue) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ViewFramePreferenceKey.self, - value: geometry.frame(in: .named("scrollView")) - ) - } - .onPreferenceChange(ViewFramePreferenceKey.self) { frame in - print("reply frame: \(frame)") - } - ) - VStack { - Text("Content") - } - .frame(maxWidth: .infinity) - .background(Color.orange) - } // end VStack - .offset(y: contentOffsetDelta) - } // end ScrollView - .coordinateSpace(name: "scrollView") - } // end body -} - -private struct ScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGPoint = .zero - - static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { } -} - -private struct ViewFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { } -} - -//struct ScrollView: View { -// let axes: Axis.Set -// let showsIndicators: Bool -// let offsetChanged: (CGPoint) -> Void -// let content: Content -// -// init( -// axes: Axis.Set = .vertical, -// showsIndicators: Bool = true, -// offsetChanged: @escaping (CGPoint) -> Void = { _ in }, -// @ViewBuilder content: () -> Content -// ) { -// self.axes = axes -// self.showsIndicators = showsIndicators -// self.offsetChanged = offsetChanged -// self.content = content() -// } -// -// var body: some View { -// SwiftUI.ScrollView(axes, showsIndicators: showsIndicators) { -// GeometryReader { geometry in -// Color.clear.preference( -// key: ScrollOffsetPreferenceKey.self, -// value: geometry.frame(in: .named("scrollView")).origin -// ) -// }.frame(width: 0, height: 0) -// content -// } -// .coordinateSpace(name: "scrollView") -// .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged) -// } -//} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 240e756a1..4c0a71f60 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -8,15 +8,18 @@ import os.log import UIKit import SwiftUI +import Combine public final class ComposeContentViewController: UIViewController { let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController") + var disposeBag = Set() public var viewModel: ComposeContentViewModel! let tableView: ComposeTableView = { let tableView = ComposeTableView() + tableView.estimatedRowHeight = UITableView.automaticDimension tableView.alwaysBounceVertical = true tableView.separatorStyle = .none tableView.tableFooterView = UIView() @@ -45,6 +48,96 @@ extension ComposeContentViewController { tableView.delegate = self viewModel.setupDataSource(tableView: tableView) + + // setup snap behavior + Publishers.CombineLatest( + viewModel.$replyToCellFrame, + viewModel.$scrollViewState + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] replyToCellFrame, scrollViewState in + guard let self = self else { return } + guard replyToCellFrame != .zero else { return } + switch scrollViewState { + case .fold: + self.tableView.contentInset.top = -replyToCellFrame.height + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: set contentInset.top: -%s", ((#file as NSString).lastPathComponent), #line, #function, replyToCellFrame.height.description) + case .expand: + self.tableView.contentInset.top = 0 + } + } + .store(in: &disposeBag) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + public override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate { [weak self] coordinatorContext in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } + } +} + +// MARK: - UIScrollViewDelegate +extension ComposeContentViewController { + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard scrollView === tableView else { return } + + let replyToCellFrame = viewModel.replyToCellFrame + guard replyToCellFrame != .zero else { return } + + // try to find some patterns: + // print(""" + // repliedToCellFrame: \(viewModel.repliedToCellFrame.value.height) + // scrollView.contentOffset.y: \(scrollView.contentOffset.y) + // scrollView.contentSize.height: \(scrollView.contentSize.height) + // scrollView.frame: \(scrollView.frame) + // scrollView.adjustedContentInset.top: \(scrollView.adjustedContentInset.top) + // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) + // """) + + switch viewModel.scrollViewState { + case .fold: + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fold") + guard velocity.y < 0 else { return } + let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + if offsetY < -44 { + tableView.contentInset.top = 0 + targetContentOffset.pointee = CGPoint(x: 0, y: -scrollView.adjustedContentInset.top) + viewModel.scrollViewState = .expand + } + + case .expand: + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): expand") + guard velocity.y > 0 else { return } + // check if top across + let topOffset = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) - replyToCellFrame.height + + // check if bottom bounce + let bottomOffsetY = scrollView.contentOffset.y + (scrollView.frame.height - scrollView.adjustedContentInset.bottom) + let bottomOffset = bottomOffsetY - scrollView.contentSize.height + + if topOffset > 44 { + // do not interrupt user scrolling + viewModel.scrollViewState = .fold + } else if bottomOffset > 44 { + tableView.contentInset.top = -replyToCellFrame.height + targetContentOffset.pointee = CGPoint(x: 0, y: -replyToCellFrame.height) + viewModel.scrollViewState = .fold + } + } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift index e1ad561ef..3f6028b56 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+DataSource.swift @@ -8,6 +8,7 @@ import UIKit import MastodonCore import CoreDataStack +import UIHostingConfigurationBackport extension ComposeContentViewModel { @@ -25,21 +26,37 @@ extension ComposeContentViewModel { enum Section: CaseIterable { case replyTo case status - case attachment - case poll } - private func setupTableViewCell(tableView: UITableView) { + private func setupTableViewCell(tableView: UITableView) { + composeContentTableViewCell.contentConfiguration = UIHostingConfigurationBackport { + ComposeContentView(viewModel: self) + } + + $contentCellFrame + .map { $0.height } + .removeDuplicates() + .sink { [weak self] height in + guard let self = self else { return } + guard !tableView.visibleCells.isEmpty else { return } + UIView.performWithoutAnimation { + tableView.beginUpdates() + self.composeContentTableViewCell.frame.size.height = height + tableView.endUpdates() + } + } + .store(in: &disposeBag) + switch kind { case .post: break case .reply(let status): let cell = composeReplyToTableViewCell // bind frame publisher -// cell.framePublisher -// .receive(on: DispatchQueue.main) -// .assign(to: \.repliedToCellFrame, on: self) -// .store(in: &cell.disposeBag) + cell.$framePublisher + .receive(on: DispatchQueue.main) + .assign(to: \.replyToCellFrame, on: self) + .store(in: &cell.disposeBag) // set initial width cell.statusView.frame.size.width = tableView.frame.width @@ -70,8 +87,6 @@ extension ComposeContentViewModel: UITableViewDataSource { default: return 0 } case .status: return 1 - case .attachment: return 1 - case .poll: return 1 } } @@ -80,11 +95,7 @@ extension ComposeContentViewModel: UITableViewDataSource { case .replyTo: return composeReplyToTableViewCell case .status: - return UITableViewCell() - case .attachment: - return UITableViewCell() - case .poll: - return UITableViewCell() + return composeContentTableViewCell } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 9f0eed2fe..24736cc5e 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -5,29 +5,79 @@ // Created by MainasuK on 22/9/30. // +import os.log import Foundation +import Combine import CoreDataStack import MastodonCore +import Meta +import MastodonMeta public final class ComposeContentViewModel: NSObject, ObservableObject { + let logger = Logger(subsystem: "ComposeContentViewModel", category: "ViewModel") + + var disposeBag = Set() + // tableViewCell let composeReplyToTableViewCell = ComposeReplyToTableViewCell() + let composeContentTableViewCell = ComposeContentTableViewCell() // input let context: AppContext let kind: Kind + + @Published var viewLayoutFrame = ViewLayoutFrame() + @Published var authContext: AuthContext + + // output + + // content + @Published public var initialContent = "" + @Published public var content = "" + @Published public var contentWeightedLength = 0 + @Published public var isContentEmpty = true + @Published public var isContentValid = true + @Published public var isContentEditing = false + + // author + @Published var avatarURL: URL? + @Published var name: MetaContent = PlaintextMetaContent(string: "") + @Published var username: String = "" + + // UI & UX + @Published var replyToCellFrame: CGRect = .zero + @Published var contentCellFrame: CGRect = .zero + @Published var scrollViewState: ScrollViewState = .fold + public init( context: AppContext, + authContext: AuthContext, kind: Kind ) { self.context = context + self.authContext = authContext self.kind = kind super.init() // end init + + // bind author + $authContext + .sink { [weak self] authContext in + guard let self = self else { return } + guard let user = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return } + self.avatarURL = user.avatarImageURL() + self.name = user.nameMetaContent ?? PlaintextMetaContent(string: user.displayNameWithFallback) + self.username = user.acctWithDomain + } + .store(in: &disposeBag) } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ComposeContentViewModel { @@ -38,7 +88,7 @@ extension ComposeContentViewModel { case reply(status: ManagedObjectRecord) } - public enum ViewState { + public enum ScrollViewState { case fold // snap to input case expand // snap to reply } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusContentTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift similarity index 89% rename from MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusContentTableViewCell.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift index e9b8cf068..3a646f1fc 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/TableViewCell/ComposeContentTableViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeStatusContentTableViewCell.swift +// ComposeContentTableViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-6-28. @@ -12,16 +12,16 @@ import MetaTextKit import UITextView_Placeholder import MastodonAsset import MastodonLocalization -import MastodonUI +import UIHostingConfigurationBackport //protocol ComposeStatusContentTableViewCellDelegate: AnyObject { // func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool //} -final class ComposeStatusContentTableViewCell: UITableViewCell { +final class ComposeContentTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "ComposeContentTableViewCell", category: "View") -// let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "View") -// // var disposeBag = Set() // weak var delegate: ComposeStatusContentTableViewCellDelegate? // @@ -74,27 +74,26 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { // metaText.delegate = nil // metaText.textView.delegate = nil // } -// -// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { -// super.init(style: style, reuseIdentifier: reuseIdentifier) -// _init() -// } -// -// required init?(coder: NSCoder) { -// super.init(coder: coder) -// _init() -// } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } } -extension ComposeStatusContentTableViewCell { +extension ComposeContentTableViewCell { + + private func _init() { + selectionStyle = .none + layer.zPosition = 999 + backgroundColor = .clear -// private func _init() { -// selectionStyle = .none -// layer.zPosition = 999 -// backgroundColor = .clear -// preservesSuperviewLayoutMargins = true -// // let containerStackView = UIStackView() // containerStackView.axis = .vertical // containerStackView.translatesAutoresizingMaskIntoConstraints = false @@ -134,7 +133,7 @@ extension ComposeStatusContentTableViewCell { // metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).priority(.defaultHigh), // ]) // statusContentWarningEditorView.textView.delegate = self -// } + } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift new file mode 100644 index 000000000..2b9f79321 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift @@ -0,0 +1,97 @@ +// +// ComposeContentView.swift +// +// +// Created by MainasuK on 22/9/30. +// + +import os.log +import SwiftUI +import MastodonLocalization + +public struct ComposeContentView: View { + + static let logger = Logger(subsystem: "ComposeContentView", category: "View") + var logger: Logger { ComposeContentView.logger } + + static var margin: CGFloat = 16 + + @ObservedObject var viewModel: ComposeContentViewModel + + public var body: some View { + VStack(spacing: .zero) { + Group { + authorView + .padding(.top, 14) + MetaTextViewRepresentable( + string: $viewModel.content, + width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2, + configurationHandler: { metaText in + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = UIColor.secondaryLabel + return NSAttributedString( + string: L10n.Scene.Compose.contentInputPlaceholder, + attributes: attributes + ) + }() + metaText.textView.keyboardType = .twitter + // metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue + // metaText.textView.delegate = viewModel + // metaText.delegate = viewModel + metaText.textView.becomeFirstResponder() + } + ) + .frame(minHeight: 100) + .fixedSize(horizontal: false, vertical: true) + } + .background( + GeometryReader { proxy in + Color.clear.preference(key: ViewFramePreferenceKey.self, value: proxy.frame(in: .local)) + } + .onPreferenceChange(ViewFramePreferenceKey.self) { frame in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content frame: \(frame.debugDescription)") + viewModel.contentCellFrame = frame + } + ) + Spacer() + } // end VStack + .padding(.horizontal, ComposeContentView.margin) +// .frame(alignment: .top) + } // end body +} + +extension ComposeContentView { + var authorView: some View { + HStack(spacing: 8) { + AnimatedImage(imageURL: viewModel.avatarURL) + .frame(width: 46, height: 46) + .background(Color(UIColor.systemFill)) + .cornerRadius(12) + VStack(alignment: .leading, spacing: 4) { + Spacer() + MetaLabelRepresentable( + textStyle: .statusName, + metaContent: viewModel.name + ) + Text(viewModel.username) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + Spacer() + } + } +} + +//private struct ScrollOffsetPreferenceKey: PreferenceKey { +// static var defaultValue: CGPoint = .zero +// +// static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { } +//} + +private struct ViewFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { } +} diff --git a/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaLabelRepresentable.swift b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaLabelRepresentable.swift new file mode 100644 index 000000000..7a671494a --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaLabelRepresentable.swift @@ -0,0 +1,47 @@ +// +// MetaLabelRepresentable.swift +// +// +// Created by MainasuK on 22/10/11. +// + +import UIKit +import SwiftUI +import MastodonCore +import MetaTextKit + +public struct MetaLabelRepresentable: UIViewRepresentable { + + public let textStyle: MetaLabel.Style + public let metaContent: MetaContent + + public init( + textStyle: MetaLabel.Style, + metaContent: MetaContent + ) { + self.textStyle = textStyle + self.metaContent = metaContent + } + + public func makeUIView(context: Context) -> MetaLabel { + let view = MetaLabel(style: textStyle) + view.isUserInteractionEnabled = false + return view + } + + public func updateUIView(_ view: MetaLabel, context: Context) { + view.configure(content: metaContent) + } + +} + +#if DEBUG +struct MetaLabelRepresentable_Preview: PreviewProvider { + static var previews: some View { + MetaLabelRepresentable( + textStyle: .statusUsername, + metaContent: PlaintextMetaContent(string: "Name") + ) + } +} +#endif diff --git a/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift new file mode 100644 index 000000000..8796feb06 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift @@ -0,0 +1,82 @@ +// +// MetaTextViewRepresentable.swift +// +// +// Created by MainasuK Cirno on 2021-7-16. +// + +import UIKit +import SwiftUI +import UITextView_Placeholder +import MetaTextKit +import MastodonAsset +import MastodonCore + +public struct MetaTextViewRepresentable: UIViewRepresentable { + + let metaText = MetaText() + + // input + @Binding var string: String + let width: CGFloat + + // handler + let configurationHandler: (MetaText) -> Void + + public func makeUIView(context: Context) -> MetaTextView { + let textView = metaText.textView + + textView.backgroundColor = .clear // clear background + textView.textContainer.lineFragmentPadding = 0 // remove leading inset + textView.isScrollEnabled = false // enable dynamic height + + // set width constraint + textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textView.widthAnchor.constraint(equalToConstant: width).priority(.required - 1) + ]) + // make textView horizontal filled + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // setup editor appearance + let font = UIFont.preferredFont(forTextStyle: .body) + metaText.textView.font = font + metaText.textAttributes = [ + .font: font, + .foregroundColor: UIColor.label, + ] + metaText.linkAttributes = [ + .font: font, + .foregroundColor: Asset.Colors.brand.color, + ] + + configurationHandler(metaText) + + metaText.configure(content: PlaintextMetaContent(string: string)) + + return textView + } + + public func updateUIView(_ metaTextView: MetaTextView, context: Context) { + // update layout + context.coordinator.widthLayoutConstraint.constant = width + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public class Coordinator: NSObject, UITextViewDelegate { + let view: MetaTextViewRepresentable + var widthLayoutConstraint: NSLayoutConstraint! + + init(_ view: MetaTextViewRepresentable) { + self.view = view + super.init() + + widthLayoutConstraint = view.metaText.textView.widthAnchor.constraint(equalToConstant: 100) + widthLayoutConstraint.isActive = true + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Utility/ViewLayoutFrame.swift b/MastodonSDK/Sources/MastodonUI/View/Utility/ViewLayoutFrame.swift new file mode 100644 index 000000000..183364abc --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Utility/ViewLayoutFrame.swift @@ -0,0 +1,57 @@ +// +// ViewLayoutFrame.swift +// +// +// Created by MainasuK on 2022-8-17. +// + +import os.log +import UIKit +import CoreGraphics + +public struct ViewLayoutFrame { + let logger = Logger(subsystem: "ViewLayoutFrame", category: "ViewLayoutFrame") + + public var layoutFrame: CGRect + public var safeAreaLayoutFrame: CGRect + public var readableContentLayoutFrame: CGRect + + public init( + layoutFrame: CGRect = .zero, + safeAreaLayoutFrame: CGRect = .zero, + readableContentLayoutFrame: CGRect = .zero + ) { + self.layoutFrame = layoutFrame + self.safeAreaLayoutFrame = safeAreaLayoutFrame + self.readableContentLayoutFrame = readableContentLayoutFrame + } +} + +extension ViewLayoutFrame { + public mutating func update(view: UIView) { + guard view.window != nil else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): layoutFrame update for a view without attached window. Skip this invalid update") + return + } + + let layoutFrame = view.frame + if self.layoutFrame != layoutFrame { + self.layoutFrame = layoutFrame + } + + let safeAreaLayoutFrame = view.safeAreaLayoutGuide.layoutFrame + if self.safeAreaLayoutFrame != safeAreaLayoutFrame { + self.safeAreaLayoutFrame = safeAreaLayoutFrame + } + + let readableContentLayoutFrame = view.readableContentGuide.layoutFrame + if self.readableContentLayoutFrame != readableContentLayoutFrame { + self.readableContentLayoutFrame = readableContentLayoutFrame + } + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): layoutFrame: \(layoutFrame.debugDescription)") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): safeAreaLayoutFrame: \(safeAreaLayoutFrame.debugDescription)") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): readableContentLayoutFrame: \(readableContentLayoutFrame.debugDescription)") + + } +}