feat: add counter and emoji picker activity indicator

This commit is contained in:
CMK 2021-03-26 19:16:19 +08:00
parent 59889cd683
commit 87a6a4df77
13 changed files with 190 additions and 37 deletions

View File

@ -1171,8 +1171,8 @@
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */,
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
);
path = Compose;
sourceTree = "<group>";

View File

@ -85,15 +85,8 @@ extension ComposeStatusSection {
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
cell.statusContentWarningEditorView.alpha = 1
} completion: { _ in
if isContentWarningComposing {
cell.statusContentWarningEditorView.textView.becomeFirstResponder()
}
// do nothing
}
// restore responder if needs
if cell.statusContentWarningEditorView.textView.isFirstResponder {
cell.textEditorView.isEditing = true
}
}
.store(in: &cell.disposeBag)
cell.contentWarningContent

View File

@ -75,10 +75,10 @@ internal enum Asset {
internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault")
internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled")
internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow")
internal static let lightBackground = ColorAsset(name: "Colors/lightBackground")
internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue")
internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed")
internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray")
internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled")
internal static let lightInactive = ColorAsset(name: "Colors/lightInactive")

View File

@ -10,6 +10,7 @@ import UIKit
import Combine
protocol ComposeStatusPollOptionCollectionViewCellDelegate: class {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?)
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField)
}
@ -132,6 +133,12 @@ extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextF
// MARK: - UITextFieldDelegate
extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeStatusPollOptionCollectionViewCell(self, textFieldDidBeginEditing: textField)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
if textField === pollOptionView.optionTextField {

View File

@ -10,6 +10,7 @@ import UIKit
import Combine
import PhotosUI
import Kingfisher
import MastodonSDK
import TwitterTextEditor
final class ComposeViewController: UIViewController, NeedsDependency {
@ -102,14 +103,18 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return documentPickerController
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewController {
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100))
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .readableContent
// section.interGroupSpacing = 10
@ -232,22 +237,61 @@ extension ComposeViewController {
})
.store(in: &disposeBag)
// bind publish bar button state
viewModel.isPublishBarButtonItemEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: publishBarButtonItem)
.store(in: &disposeBag)
// bind media button toolbar state
viewModel.isMediaToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.mediaButton)
.store(in: &disposeBag)
// bind poll button toolbar state
viewModel.isPollToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.pollButton)
.store(in: &disposeBag)
// bind custom emojis
// bind image picker toolbar state
viewModel.attachmentServices
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4
self.resetImagePicker()
}
.store(in: &disposeBag)
// bind visibility toolbar UI
viewModel.selectedStatusVisibility
.receive(on: DispatchQueue.main)
.sink { [weak self] type in
guard let self = self else { return }
self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal)
}
.store(in: &disposeBag)
viewModel.characterCount
.receive(on: DispatchQueue.main)
.sink { [weak self] characterCount in
guard let self = self else { return }
let count = ComposeViewModel.composeContentLimit - characterCount
self.composeToolbarView.characterCountLabel.text = "\(count)"
switch count {
case _ where count < 0:
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color
default:
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color
}
}
.store(in: &disposeBag)
// bind text editor for custom emojis update event
viewModel.customEmojiViewModel
.compactMap { $0?.emojis }
.switchToLatest()
@ -261,22 +305,24 @@ extension ComposeViewController {
})
.store(in: &disposeBag)
// bind image picker toolbar state
viewModel.attachmentServices
// bind custom emoji picker UI
viewModel.customEmojiViewModel
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4
self.resetImagePicker()
.map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in
guard let viewModel = viewModel else {
return Just([]).eraseToAnyPublisher()
}
return viewModel.emojis.eraseToAnyPublisher()
}
.store(in: &disposeBag)
viewModel.selectedStatusVisibility
.receive(on: DispatchQueue.main)
.sink { [weak self] type in
.switchToLatest()
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal)
}
if emojis.isEmpty {
self.customEmojiPickerInputView.activityIndicatorView.startAnimating()
} else {
self.customEmojiPickerInputView.activityIndicatorView.stopAnimating()
}
})
.store(in: &disposeBag)
}
@ -317,6 +363,25 @@ extension ComposeViewController {
textEditorView()?.isEditing = true
}
private func contentWarningEditorTextView() -> UITextView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers
for item in items {
switch item {
case .input:
guard let indexPath = diffableDataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else {
continue
}
return cell.statusContentWarningEditorView.textView
default:
continue
}
}
return nil
}
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
guard case .pollOption = item else { return nil }
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
@ -398,7 +463,7 @@ extension ComposeViewController {
imagePicker.delegate = self
return imagePicker
}
}
extension ComposeViewController {
@ -587,6 +652,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
attributedString.addAttributes(attributes, range: match.range)
}
if string.count > ComposeViewModel.composeContentLimit {
var attributes = [NSAttributedString.Key: Any]()
attributes[.foregroundColor] = Asset.Colors.danger.color
let boundStart = string.index(string.startIndex, offsetBy: ComposeViewModel.composeContentLimit)
let boundEnd = string.endIndex
let range = boundStart..<boundEnd
attributedString.addAttributes(attributes, range: NSRange(range, in: string))
}
completion(attributedString)
}
}
@ -631,7 +705,20 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
// restore first responder for text editor when content warning dismiss
if viewModel.isContentWarningComposing.value {
if contentWarningEditorTextView()?.isFirstResponder == true {
markTextEditorViewBecomeFirstResponser()
}
}
// toggle composing status
viewModel.isContentWarningComposing.value.toggle()
// active content warning after toggled
if viewModel.isContentWarningComposing.value {
contentWarningEditorTextView()?.becomeFirstResponder()
}
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) {
@ -773,6 +860,14 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) {
// FIXME: make poll section visible
// DispatchQueue.main.async {
// self.collectionView.scroll(to: .bottom, animated: true)
// }
}
// handle delete backward event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
guard (text ?? "").isEmpty else { return }

View File

@ -55,7 +55,6 @@ extension ComposeViewModel {
self.pollOptionAttributes.value = pollOptionAttributes
}
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
snapshot.appendSections([.repliedTo, .status, .attachment, .poll])

View File

@ -5,6 +5,7 @@
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import CoreData
@ -14,6 +15,8 @@ import MastodonSDK
final class ComposeViewModel {
static let composeContentLimit: Int = 500
var disposeBag = Set<AnyCancellable>()
// input
@ -48,11 +51,13 @@ final class ComposeViewModel {
let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
let isMediaToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let characterCount = CurrentValueSubject<Int, Never>(0)
// custom emojis
var customEmojiViewModelSubscription: AnyCancellable?
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
let isLoadingCustomEmoji = CurrentValueSubject<Bool, Never>(false)
// attachment
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
@ -109,10 +114,30 @@ final class ComposeViewModel {
}
.store(in: &disposeBag)
// bind character count
Publishers.CombineLatest3(
composeStatusAttribute.composeContent.eraseToAnyPublisher(),
composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(),
composeStatusAttribute.contentWarningContent.eraseToAnyPublisher()
)
.map { composeContent, isContentWarningComposing, contentWarningContent -> Int in
let composeContent = composeContent ?? ""
var count = composeContent.count
if isContentWarningComposing {
count += contentWarningContent.count
}
return count
}
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
// bind compose bar button item UI state
let isComposeContentEmpty = composeStatusAttribute.composeContent
.map { ($0 ?? "").isEmpty }
let isComposeContentValid = Just(true).eraseToAnyPublisher()
let isComposeContentValid = composeStatusAttribute.composeContent
.map { composeContent -> Bool in
let composeContent = composeContent ?? ""
return composeContent.count <= ComposeViewModel.composeContentLimit
}
let isMediaEmpty = attachmentServices
.map { $0.isEmpty }
let isMediaUploadAllSuccess = attachmentServices
@ -278,6 +303,10 @@ final class ComposeViewModel {
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewModel {
@ -301,6 +330,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate {
extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) {
// trigger update
pollOptionAttributes.value = pollOptionAttributes.value
// pollOptionAttributes.value = pollOptionAttributes.value
}
}

View File

@ -59,6 +59,14 @@ final class ComposeToolbarView: UIView {
return button
}()
let characterCountLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .regular)
label.text = "500"
label.textColor = Asset.Colors.Label.secondary.color
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -102,6 +110,16 @@ extension ComposeToolbarView {
])
}
characterCountLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(characterCountLabel)
NSLayoutConstraint.activate([
characterCountLabel.topAnchor.constraint(equalTo: topAnchor),
characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8),
characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
])
characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
mediaButton.menu = createMediaContextMenu()
mediaButton.showsMenuAsPrimaryAction = true
pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside)

View File

@ -17,6 +17,8 @@ final class CustomEmojiPickerInputView: UIInputView {
return collectionView
}()
let activityIndicatorView = UIActivityIndicatorView(style: .large)
override init(frame: CGRect, inputViewStyle: UIInputView.Style) {
super.init(frame: frame, inputViewStyle: inputViewStyle)
_init()
@ -33,6 +35,13 @@ extension CustomEmojiPickerInputView {
private func _init() {
allowsSelfSizing = true
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
collectionView.translatesAutoresizingMaskIntoConstraints = false
addSubview(collectionView)
NSLayoutConstraint.activate([
@ -41,6 +50,9 @@ extension CustomEmojiPickerInputView {
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.startAnimating()
}
}

View File

@ -15,7 +15,7 @@ final class HomeTimelineNavigationBarView {
}()
static let offlineView: UIView = {
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightDangerRed.color)
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color)
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline)
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
return view

View File

@ -106,7 +106,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let usernameErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -146,7 +146,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let emailErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -177,7 +177,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let passwordErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -201,7 +201,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let reasonErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()

View File

@ -198,10 +198,10 @@ extension MastodonRegisterViewModel {
let attributeString = NSMutableAttributedString()
let image = MastodonRegisterViewModel.xmarkImage(font: font)
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color))
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.danger.color))
attributeString.append(NSAttributedString(string: " "))
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.danger.color])
attributeString.append(promptAttributedString)
return attributeString