feat: restore auto-complete for compose scene content input

This commit is contained in:
CMK 2022-11-13 16:04:29 +08:00
parent f7d0186bf3
commit e7ef0f79c7
10 changed files with 317 additions and 258 deletions

View File

@ -376,7 +376,6 @@
DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; };
DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; };
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC6461526A170AB00B0E31B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC6461426A170AB00B0E31B /* ComposeViewController.swift */; };
DBC6461826A170AB00B0E31B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBC6461626A170AB00B0E31B /* MainInterface.storyboard */; };
@ -962,7 +961,6 @@
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DBC6461426A170AB00B0E31B /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
@ -2520,7 +2518,6 @@
DBBC24D526A54BCB00398BB9 /* Helper */ = {
isa = PBXGroup;
children = (
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
);
path = Helper;
@ -3246,7 +3243,6 @@
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */,
DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */,
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,

View File

@ -106,13 +106,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
// let composeToolbarBackgroundView = UIView()
//
//
// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
// let viewController = AutoCompleteViewController()
// viewController.viewModel = AutoCompleteViewModel(context: context, authContext: viewModel.authContext)
// viewController.delegate = self
// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel
// return viewController
// }()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -243,33 +237,6 @@ extension ComposeViewController {
// // update layout when keyboard show/dismiss
// view.layoutIfNeeded()
//
//
// // bind auto-complete
// viewModel.$autoCompleteInfo
// .receive(on: DispatchQueue.main)
// .sink { [weak self] info in
// guard let self = self else { return }
// let textEditorView = self.textEditorView
// if self.autoCompleteViewController.view.superview == nil {
// self.autoCompleteViewController.view.frame = self.view.bounds
// // add to container view. seealso: `viewDidLayoutSubviews()`
// self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view)
// self.addChild(self.autoCompleteViewController)
// self.autoCompleteViewController.didMove(toParent: self)
// self.autoCompleteViewController.view.isHidden = true
// self.tableView.autoCompleteViewController = self.autoCompleteViewController
// }
// self.updateAutoCompleteViewControllerLayout()
// self.autoCompleteViewController.view.isHidden = info == nil
// guard let info = info else { return }
// let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
// self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY
// self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
// self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
// }
// .store(in: &disposeBag)
//
// // bind publish bar button state
// viewModel.$isPublishBarButtonItemEnabled
// .receive(on: DispatchQueue.main)
@ -431,23 +398,6 @@ extension ComposeViewController {
// viewModel.traitCollectionDidChangePublisher.send()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateAutoCompleteViewControllerLayout()
}
private func updateAutoCompleteViewControllerLayout() {
// pin autoCompleteViewController frame to current view
// if let containerView = autoCompleteViewController.view.superview {
// let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
// if viewFrameInWindow.origin.x != 0 {
// autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
// }
// autoCompleteViewController.view.frame.size.width = view.frame.width
// }
}
}
//extension ComposeViewController {
@ -661,126 +611,11 @@ extension ComposeViewController {
// return true
// }
//
// func textViewDidChange(_ textView: UITextView) {
// switch textView {
// case textEditorView.textView:
// // update model
// let metaText = self.textEditorView
// let backedString = metaText.backedString
// viewModel.composeStatusAttribute.composeContent = backedString
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)")
//
// // configure auto completion
// setupAutoComplete(for: textView)
// default:
// assertionFailure()
// }
// }
//
// struct AutoCompleteInfo {
// // model
// let inputText: Substring
// // range
// let symbolRange: Range<String.Index>
// let symbolString: Substring
// let toCursorRange: Range<String.Index>
// let toCursorString: Substring
// let toHighlightEndRange: Range<String.Index>
// let toHighlightEndString: Substring
// // geometry
// var textBoundingRect: CGRect = .zero
// var symbolBoundingRect: CGRect = .zero
// }
//
// private func setupAutoComplete(for textView: UITextView) {
// guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else {
// viewModel.autoCompleteInfo = nil
// return
// }
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
//
// // get layout text bounding rect
// var glyphRange = NSRange()
// textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange)
// let textContainer = textView.layoutManager.textContainers[0]
// let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
//
// let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes
// guard textBoundingRect.size != .zero else {
// viewModel.autoCompleteRetryLayoutTimes += 1
// // avoid infinite loop
// guard retryLayoutTimes < 3 else { return }
// // needs retry calculate layout when the rect position changing
// DispatchQueue.main.async {
// self.setupAutoComplete(for: textView)
// }
// return
// }
// viewModel.autoCompleteRetryLayoutTimes = 0
//
// // get symbol bounding rect
// textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange)
// let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
//
// // set bounding rect and trigger layout
// autoCompletion.textBoundingRect = textBoundingRect
// autoCompletion.symbolBoundingRect = symbolBoundingRect
// viewModel.autoCompleteInfo = autoCompletion
// }
//
// private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? {
// guard let text = textView.text,
// textView.selectedRange.location > 0, !text.isEmpty,
// let selectedRange = Range(textView.selectedRange, in: text) else {
// return nil
// }
// let cursorIndex = selectedRange.upperBound
// let _highlightStartIndex: String.Index? = {
// var index = text.index(before: cursorIndex)
// while index > text.startIndex {
// let char = text[index]
// if char == "@" || char == "#" || char == ":" {
// return index
// }
// index = text.index(before: index)
// }
// assert(index == text.startIndex)
// let char = text[index]
// if char == "@" || char == "#" || char == ":" {
// return index
// } else {
// return nil
// }
// }()
//
// guard let highlightStartIndex = _highlightStartIndex else { return nil }
// let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
//
// guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
// guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
// let matchStartIndex = matchRange.lowerBound
// let matchEndIndex = matchRange.upperBound
//
// guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
// let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
// let symbolString = text[symbolRange]
// let toCursorRange = highlightStartIndex..<cursorIndex
// let toCursorString = text[toCursorRange]
// let toHighlightEndRange = matchStartIndex..<matchEndIndex
// let toHighlightEndString = text[toHighlightEndRange]
//
// let inputText = toHighlightEndString
// let autoCompleteInfo = AutoCompleteInfo(
// inputText: inputText,
// symbolRange: symbolRange,
// symbolString: symbolString,
// toCursorRange: toCursorRange,
// toCursorString: toCursorString,
// toHighlightEndRange: toHighlightEndRange,
// toHighlightEndString: toHighlightEndString
// )
// return autoCompleteInfo
// }
//
// func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
// switch textView {

View File

@ -41,8 +41,6 @@ final class ComposeViewModel: NSObject {
//
// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
// @Published var repliedToCellFrame: CGRect = .zero
// @Published var autoCompleteRetryLayoutTimes = 0
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
// var isViewAppeared = false

View File

@ -88,7 +88,7 @@ extension AutoCompleteViewController {
])
tableView.delegate = self
// viewModel.setupDiffableDataSource(tableView: tableView)
viewModel.setupDiffableDataSource(tableView: tableView)
// bind to layout chevron
viewModel.symbolBoundingRect

View File

@ -6,17 +6,18 @@
//
import UIKit
import MastodonCore
extension AutoCompleteViewModel {
// func setupDiffableDataSource(
// tableView: UITableView
// ) {
// diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
//
// var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
// snapshot.appendSections([.main])
// diffableDataSource?.apply(snapshot)
// }
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(tableView: tableView)
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -20,6 +20,7 @@ public final class ComposeContentViewController: UIViewController {
public var viewModel: ComposeContentViewModel!
private(set) lazy var composeContentToolbarViewModel = ComposeContentToolbarView.ViewModel(delegate: self)
// tableView container
let tableView: ComposeTableView = {
let tableView = ComposeTableView()
tableView.estimatedRowHeight = UITableView.automaticDimension
@ -29,6 +30,17 @@ public final class ComposeContentViewController: UIViewController {
return tableView
}()
// auto complete
private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
let viewController = AutoCompleteViewController()
viewController.viewModel = AutoCompleteViewModel(context: viewModel.context, authContext: viewModel.authContext)
viewController.delegate = self
// viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel
return viewController
}()
// toolbar
lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel)
var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeContentToolbarBackgroundView = UIView()
@ -218,6 +230,32 @@ extension ComposeContentViewController {
}
.store(in: &disposeBag)
// bind auto-complete
viewModel.$autoCompleteInfo
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self = self else { return }
guard let textView = self.viewModel.contentMetaText?.textView else { return }
if self.autoCompleteViewController.view.superview == nil {
self.autoCompleteViewController.view.frame = self.view.bounds
// add to container view. seealso: `viewDidLayoutSubviews()`
self.viewModel.composeContentTableViewCell.contentView.addSubview(self.autoCompleteViewController.view)
self.addChild(self.autoCompleteViewController)
self.autoCompleteViewController.didMove(toParent: self)
self.autoCompleteViewController.view.isHidden = true
self.tableView.autoCompleteViewController = self.autoCompleteViewController
}
self.updateAutoCompleteViewControllerLayout()
self.autoCompleteViewController.view.isHidden = info == nil
guard let info = info else { return }
let symbolBoundingRectInContainer = textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
print(info.symbolBoundingRect)
self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY + self.viewModel.contentTextViewFrame.minY
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
}
.store(in: &disposeBag)
// bind toolbar
bindToolbarViewModel()
}
@ -226,6 +264,7 @@ extension ComposeContentViewController {
super.viewDidLayoutSubviews()
viewModel.viewLayoutFrame.update(view: view)
updateAutoCompleteViewControllerLayout()
}
public override func viewSafeAreaInsetsDidChange() {
@ -264,6 +303,17 @@ extension ComposeContentViewController {
viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength)
viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength)
}
private func updateAutoCompleteViewControllerLayout() {
// pin autoCompleteViewController frame to current view
if let containerView = autoCompleteViewController.view.superview {
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
if viewFrameInWindow.origin.x != 0 {
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
}
autoCompleteViewController.view.frame.size.width = view.frame.width
}
}
}
// MARK: - UIScrollViewDelegate
@ -427,3 +477,13 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate {
}
}
}
// MARK: - AutoCompleteViewControllerDelegate
extension ComposeContentViewController: AutoCompleteViewControllerDelegate {
func autoCompleteViewController(
_ viewController: AutoCompleteViewController,
didSelectItem item: AutoCompleteItem
) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select item: \(String(describing: item))")
}
}

View File

@ -0,0 +1,203 @@
//
// ComposeContentViewModel+UITextViewDelegate.swift
//
//
// Created by MainasuK on 2022/11/13.
//
import os.log
import UIKit
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {
// Note:
// Xcode warning:
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
//
// Just ignore the warning and see what will happen
switch textView {
case contentMetaText?.textView:
isContentEditing = true
case contentWarningMetaText?.textView:
isContentWarningEditing = true
default:
assertionFailure()
break
}
}
public func textViewDidChange(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
// update model
guard let metaText = self.contentMetaText else {
assertionFailure()
return
}
let backedString = metaText.backedString
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)")
// configure auto completion
setupAutoComplete(for: textView)
case contentWarningMetaText?.textView:
break
default:
assertionFailure()
}
}
public func textViewDidEndEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = false
case contentWarningMetaText?.textView:
isContentWarningEditing = false
default:
assertionFailure()
break
}
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case contentMetaText?.textView:
return true
case contentWarningMetaText?.textView:
let isReturn = text == "\n"
if isReturn {
setContentTextViewFirstResponderIfNeeds()
}
return !isReturn
default:
assertionFailure()
return true
}
}
}
extension ComposeContentViewModel {
func insertContentText(text: String) {
guard let contentMetaText = self.contentMetaText else { return }
// FIXME: smart prefix and suffix
let string = contentMetaText.textStorage.string
let isEmpty = string.isEmpty
let hasPrefix = string.hasPrefix(" ")
if hasPrefix || isEmpty {
contentMetaText.textView.insertText(text)
} else {
contentMetaText.textView.insertText(" " + text)
}
}
func setContentTextViewFirstResponderIfNeeds() {
guard let contentMetaText = self.contentMetaText else { return }
guard !contentMetaText.textView.isFirstResponder else { return }
contentMetaText.textView.becomeFirstResponder()
}
func setContentWarningTextViewFirstResponderIfNeeds() {
guard let contentWarningMetaText = self.contentWarningMetaText else { return }
guard !contentWarningMetaText.textView.isFirstResponder else { return }
contentWarningMetaText.textView.becomeFirstResponder()
}
}
extension ComposeContentViewModel {
private func setupAutoComplete(for textView: UITextView) {
guard var autoCompletion = ComposeContentViewModel.scanAutoCompleteInfo(textView: textView) else {
self.autoCompleteInfo = nil
return
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
// get layout text bounding rect
var glyphRange = NSRange()
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange)
let textContainer = textView.layoutManager.textContainers[0]
let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
let retryLayoutTimes = autoCompleteRetryLayoutTimes
guard textBoundingRect.size != .zero else {
autoCompleteRetryLayoutTimes += 1
// avoid infinite loop
guard retryLayoutTimes < 3 else { return }
// needs retry calculate layout when the rect position changing
DispatchQueue.main.async {
self.setupAutoComplete(for: textView)
}
return
}
autoCompleteRetryLayoutTimes = 0
// get symbol bounding rect
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange)
let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// set bounding rect and trigger layout
autoCompletion.textBoundingRect = textBoundingRect
autoCompletion.symbolBoundingRect = symbolBoundingRect
autoCompleteInfo = autoCompletion
}
private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? {
guard let text = textView.text,
textView.selectedRange.location > 0, !text.isEmpty,
let selectedRange = Range(textView.selectedRange, in: text) else {
return nil
}
let cursorIndex = selectedRange.upperBound
let _highlightStartIndex: String.Index? = {
var index = text.index(before: cursorIndex)
while index > text.startIndex {
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
}
index = text.index(before: index)
}
assert(index == text.startIndex)
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
} else {
return nil
}
}()
guard let highlightStartIndex = _highlightStartIndex else { return nil }
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
let matchStartIndex = matchRange.lowerBound
let matchEndIndex = matchRange.upperBound
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlightStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo(
inputText: inputText,
symbolRange: symbolRange,
symbolString: symbolString,
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompleteInfo
}
}

View File

@ -34,6 +34,10 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// author (me)
@Published var authContext: AuthContext
// auto-complete info
@Published var autoCompleteRetryLayoutTimes = 0
@Published var autoCompleteInfo: AutoCompleteInfo? = nil
// output
// limit
@ -98,9 +102,9 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// UI & UX
@Published var replyToCellFrame: CGRect = .zero
@Published var contentCellFrame: CGRect = .zero
@Published var contentTextViewFrame: CGRect = .zero
@Published var scrollViewState: ScrollViewState = .fold
public init(
context: AppContext,
authContext: AuthContext,
@ -203,13 +207,30 @@ extension ComposeContentViewModel {
case mention(user: ManagedObjectRecord<MastodonUser>)
case reply(status: ManagedObjectRecord<Status>)
}
public enum ScrollViewState {
case fold // snap to input
case expand // snap to reply
}
}
extension ComposeContentViewModel {
struct AutoCompleteInfo {
// model
let inputText: Substring
// range
let symbolRange: Range<String.Index>
let symbolString: Substring
let toCursorRange: Range<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
let toHighlightEndString: Substring
// geometry
var textBoundingRect: CGRect = .zero
var symbolBoundingRect: CGRect = .zero
}
}
extension ComposeContentViewModel {
func createNewPollOptionIfCould() {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
@ -286,77 +307,6 @@ extension ComposeContentViewModel {
} // end func publisher()
}
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {
// Note:
// Xcode warning:
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
//
// Just ignore the warning and see what will happen
switch textView {
case contentMetaText?.textView:
isContentEditing = true
case contentWarningMetaText?.textView:
isContentWarningEditing = true
default:
break
}
}
public func textViewDidEndEditing(_ textView: UITextView) {
switch textView {
case contentMetaText?.textView:
isContentEditing = false
case contentWarningMetaText?.textView:
isContentWarningEditing = false
default:
break
}
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case contentMetaText?.textView:
return true
case contentWarningMetaText?.textView:
let isReturn = text == "\n"
if isReturn {
setContentTextViewFirstResponderIfNeeds()
}
return !isReturn
default:
assertionFailure()
return true
}
}
func insertContentText(text: String) {
guard let contentMetaText = self.contentMetaText else { return }
// FIXME: smart prefix and suffix
let string = contentMetaText.textStorage.string
let isEmpty = string.isEmpty
let hasPrefix = string.hasPrefix(" ")
if hasPrefix || isEmpty {
contentMetaText.textView.insertText(text)
} else {
contentMetaText.textView.insertText(" " + text)
}
}
func setContentTextViewFirstResponderIfNeeds() {
guard let contentMetaText = self.contentMetaText else { return }
guard !contentMetaText.textView.isFirstResponder else { return }
contentMetaText.textView.becomeFirstResponder()
}
func setContentWarningTextViewFirstResponderIfNeeds() {
guard let contentWarningMetaText = self.contentWarningMetaText else { return }
guard !contentWarningMetaText.textView.isFirstResponder else { return }
contentWarningMetaText.textView.becomeFirstResponder()
}
}
// MARK: - DeleteBackwardResponseTextFieldRelayDelegate
extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate {

View File

@ -18,9 +18,11 @@ public struct ComposeContentView: View {
static let logger = Logger(subsystem: "ComposeContentView", category: "View")
var logger: Logger { ComposeContentView.logger }
static let contentViewCoordinateSpace = "ComposeContentView.Content"
static var margin: CGFloat = 16
@ObservedObject var viewModel: ComposeContentViewModel
public var body: some View {
VStack(spacing: .zero) {
@ -106,6 +108,19 @@ public struct ComposeContentView: View {
.frame(minHeight: 100)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, ComposeContentView.margin)
.background(
GeometryReader { proxy in
Color.clear.preference(key: ViewFramePreferenceKey.self, value: proxy.frame(in: .named(ComposeContentView.contentViewCoordinateSpace)))
}
.onPreferenceChange(ViewFramePreferenceKey.self) { frame in
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content textView frame: \(frame.debugDescription)")
let rect = frame.standardized
viewModel.contentTextViewFrame = CGRect(
origin: frame.origin,
size: CGSize(width: floor(rect.width), height: floor(rect.height))
)
}
)
// poll
pollView
.padding(.horizontal, ComposeContentView.margin)
@ -128,6 +143,7 @@ public struct ComposeContentView: View {
)
Spacer()
} // end VStack
.coordinateSpace(name: ComposeContentView.contentViewCoordinateSpace)
} // end body
}