feat: make text editor automatic grow height during input

This commit is contained in:
CMK 2021-03-12 14:18:07 +08:00
parent 2b2759c2cc
commit d9e2453464
16 changed files with 246 additions and 50 deletions

View File

@ -178,7 +178,9 @@
"title": {
"new_toot": "New Toot",
"new_reply": "New Reply"
}
},
"content_input_placeholder": "Type or paste what's on your mind",
"compose_action": "Toot"
}
}
}

View File

@ -202,6 +202,7 @@
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; };
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
@ -464,6 +465,7 @@
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; };
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
@ -649,6 +651,7 @@
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */,
DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */,
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */,
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */,
);
path = Button;
sourceTree = "<group>";
@ -1633,6 +1636,7 @@
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,

View File

@ -6,11 +6,34 @@
//
import Foundation
import Combine
import CoreData
enum ComposeStatusItem {
case replyTo(tootObjectID: NSManagedObjectID)
case toot(replyToTootObjectID: NSManagedObjectID?)
case toot(replyToTootObjectID: NSManagedObjectID?, attribute: ComposeTootAttribute)
}
extension ComposeStatusItem: Hashable { }
extension ComposeStatusItem {
final class ComposeTootAttribute: Equatable, Hashable {
private let id = UUID()
let avatarURL = CurrentValueSubject<URL?, Never>(nil)
let displayName = CurrentValueSubject<String?, Never>(nil)
let username = CurrentValueSubject<String?, Never>(nil)
let composeContent = CurrentValueSubject<String?, Never>(nil)
static func == (lhs: ComposeTootAttribute, rhs: ComposeTootAttribute) -> Bool {
return lhs.avatarURL.value == rhs.avatarURL.value &&
lhs.displayName.value == rhs.displayName.value &&
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}

View File

@ -5,9 +5,82 @@
// Created by MainasuK Cirno on 2021-3-11.
//
import Foundation
import UIKit
import Combine
import CoreData
import CoreDataStack
enum ComposeStatusSection: Equatable, Hashable {
case repliedTo
case status
}
extension ComposeStatusSection {
enum ComposeKind {
case toot
case replyToot(tootObjectID: NSManagedObjectID)
}
}
extension ComposeStatusSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
composeKind: ComposeKind
) -> UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .replyTo(let tootObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell
// TODO:
return cell
case .toot(let replyToTootObjectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell
managedObjectContext.perform {
guard let replyToTootObjectID = replyToTootObjectID,
let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else {
cell.statusView.headerContainerStackView.isHidden = true
return
}
cell.statusView.headerContainerStackView.isHidden = false
cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)"
}
ComposeStatusSection.configureComposeTootContent(cell: cell, attribute: attribute)
// self size input cell
cell.composeContent
.receive(on: DispatchQueue.main)
.sink { text in
tableView.beginUpdates()
tableView.endUpdates()
}
.store(in: &cell.disposeBag)
return cell
}
}
}
}
extension ComposeStatusSection {
static func configureComposeTootContent(
cell: ComposeTootContentTableViewCell,
attribute: ComposeStatusItem.ComposeTootAttribute
) {
attribute.avatarURL
.receive(on: DispatchQueue.main)
.sink { avatarURL in
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL))
}
.store(in: &cell.disposeBag)
Publishers.CombineLatest(
attribute.displayName.eraseToAnyPublisher(),
attribute.username.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { displayName, username in
cell.statusView.nameLabel.text = displayName
cell.statusView.usernameLabel.text = username
}
.store(in: &cell.disposeBag)
}
}

View File

@ -48,6 +48,7 @@ internal enum Asset {
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
internal static let highlight = ColorAsset(name: "Colors/Button/highlight")
internal static let normal = ColorAsset(name: "Colors/Button/normal")
}
internal enum Icon {
internal static let photo = ColorAsset(name: "Colors/Icon/photo")

View File

@ -128,6 +128,10 @@ internal enum L10n {
internal enum Scene {
internal enum Compose {
/// Toot
internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction")
/// Type or paste what's on your mind
internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder")
internal enum Title {
/// New Reply
internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply")

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.784",
"green" : "0.682",
"red" : "0.608"
"blue" : "140",
"green" : "130",
"red" : "110"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "217",
"green" : "144",
"red" : "43"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -34,6 +34,8 @@
"Common.Controls.Timeline.LoadMore" = "Load More";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.ComposeAction" = "Toot";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
"Scene.Compose.Title.NewReply" = "New Reply";
"Scene.Compose.Title.NewToot" = "New Toot";
"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email";

View File

@ -18,6 +18,20 @@ final class ComposeViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var viewModel: ComposeViewModel!
let composeTootBarButtonItem: UIBarButtonItem = {
let button = RoundedEdgesButton(type: .custom)
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color), for: .normal)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
button.setTitleColor(.white, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16)
button.adjustsImageWhenHighlighted = false
let barButtonItem = UIBarButtonItem(customView: button)
return barButtonItem
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self))
@ -34,7 +48,6 @@ extension ComposeViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemBackground.color
viewModel.title
.receive(on: DispatchQueue.main)
.sink { [weak self] title in
@ -42,7 +55,10 @@ extension ComposeViewController {
self.title = title
}
.store(in: &disposeBag)
view.backgroundColor = Asset.Colors.Background.systemBackground.color
navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:)))
navigationItem.rightBarButtonItem = composeTootBarButtonItem
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
@ -54,9 +70,7 @@ extension ComposeViewController {
])
tableView.delegate = self
viewModel.setupDiffableDataSource(for: tableView)
viewModel.setupDiffableDataSource(for: tableView, dependency: self)
}
override func viewWillAppear(_ animated: Bool) {
@ -111,7 +125,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
// MARK: - UITableViewDelegate
extension ComposeViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
}
// MARK: - ComposeViewController

View File

@ -9,30 +9,25 @@ import UIKit
extension ComposeViewModel {
func setupDiffableDataSource(for tableView: UITableView) {
diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in
guard let self = self else { return nil }
switch item {
case .replyTo(let tootObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell
// TODO:
return cell
case .toot(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell
// TODO:
return cell
}
}
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency
) {
diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: context.managedObjectContext,
composeKind: composeKind
)
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
snapshot.appendSections([.repliedTo, .status])
switch composeKind {
case .replyToot(let tootObjectID):
snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo)
snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID)], toSection: .status)
snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status)
case .toot:
snapshot.appendItems([.toot(replyToTootObjectID: nil)], toSection: .status)
snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status)
}
diffableDataSource.apply(snapshot, animatingDifferences: false)
}

View File

@ -12,18 +12,25 @@ import CoreDataStack
final class ComposeViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let composeKind: ComposeKind
let composeKind: ComposeStatusSection.ComposeKind
let composeTootAttribute = ComposeStatusItem.ComposeTootAttribute()
let composeContent = CurrentValueSubject<String, Never>("")
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
// output
var diffableDataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
// UI & UX
let title: CurrentValueSubject<String, Never>
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
init(
context: AppContext,
composeKind: ComposeKind
composeKind: ComposeStatusSection.ComposeKind
) {
self.context = context
self.composeKind = composeKind
@ -31,14 +38,30 @@ final class ComposeViewModel {
case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot)
case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
}
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
// end init
// bind active authentication
context.authenticationService.activeMastodonAuthentication
.assign(to: \.value, on: activeAuthentication)
.store(in: &disposeBag)
activeAuthentication
.sink { [weak self] mastodonAuthentication in
guard let self = self else { return }
let mastodonUser = mastodonAuthentication?.user
let username = mastodonUser?.username ?? " "
self.composeTootAttribute.avatarURL.value = mastodonUser?.avatarImageURL()
self.composeTootAttribute.displayName.value = {
guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else {
return username
}
return displayName
}()
self.composeTootAttribute.username.value = username
}
.store(in: &disposeBag)
}
}
extension ComposeViewModel {
enum ComposeKind {
case toot
case replyToot(tootObjectID: NSManagedObjectID)
}
}

View File

@ -6,19 +6,26 @@
//
import UIKit
import Combine
import TwitterTextEditor
final class ComposeTootContentTableViewCell: UITableViewCell {
var disposeBag = Set<AnyCancellable>()
let statusView = StatusView()
let textEditorView: TextEditorView = {
let textEditorView = TextEditorView()
textEditorView.font = .preferredFont(forTextStyle: .body)
// textEditorView.scrollView.isScrollEnabled = false
textEditorView.scrollView.isScrollEnabled = false
textEditorView.isScrollEnabled = false
textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder
return textEditorView
}()
let composeContent = PassthroughSubject<String, Never>()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
@ -56,19 +63,9 @@ extension ComposeTootContentTableViewCell {
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
// let containerStackView = UIStackView()
// containerStackView.axis = .vertical
// containerStackView.spacing = 8
// containerStackView.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(containerStackView)
// NSLayoutConstraint.activate([
// containerStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
// containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
// containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
// contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 20),
// ])
// TODO:
textEditorView.changeObserver = self
}
override func didMoveToWindow() {
@ -81,3 +78,11 @@ extension ComposeTootContentTableViewCell {
extension ComposeTootContentTableViewCell {
}
// MARK: - UITextViewDelegate
extension ComposeTootContentTableViewCell: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
guard changeResult.isTextChanged else { return }
composeContent.send(textEditorView.text)
}
}

View File

@ -0,0 +1,19 @@
//
// RoundedEdgesButton.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-12.
//
import UIKit
final class RoundedEdgesButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
layer.masksToBounds = true
layer.cornerRadius = bounds.height * 0.5
}
}

View File

@ -0,0 +1,8 @@
//
// Mastodon+API+Statuses.swift
//
//
// Created by MainasuK Cirno on 2021-3-12.
//
import Foundation

View File

@ -96,6 +96,7 @@ extension Mastodon.API {
public enum Onboarding { }
public enum Polls { }
public enum Timeline { }
public enum Statuses { }
public enum Favorites { }
}