From c2c38c9307f32e1dc14e8bbd82982301b2bfd3a5 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 14 May 2021 20:02:59 +0800 Subject: [PATCH] feat: [WIP] add auto completion view for compose highlight --- Mastodon.xcodeproj/project.pbxproj | 58 ++++-- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../Section/ComposeStatusSection.swift | 3 + Mastodon/Extension/ActiveLabel.swift | 37 ---- Mastodon/Helper/MastodonRegex.swift | 20 ++ .../AutoCompleteTopChevronView.swift | 48 +++++ .../AutoCompletionViewController.swift | 24 +++ ...mposeStatusContentCollectionViewCell.swift | 30 ++- .../Scene/Compose/ComposeViewController.swift | 182 +++++++++++++++--- .../Compose/ComposeViewModel+Diffable.swift | 2 + Mastodon/Scene/Compose/ComposeViewModel.swift | 2 + 11 files changed, 321 insertions(+), 93 deletions(-) create mode 100644 Mastodon/Helper/MastodonRegex.swift create mode 100644 Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift create mode 100644 Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index df5d04df..c3555be8 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -206,8 +206,6 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; }; - DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; @@ -311,6 +309,11 @@ DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; + DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */; }; + DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; }; + DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DB6F5E35264E78E7009108F4 /* AutoCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */; }; + DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -527,7 +530,7 @@ files = ( DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, - DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */, + DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -853,6 +856,9 @@ DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; + DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompletionViewController.swift; sourceTree = ""; }; + DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -993,7 +999,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */, + DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -1874,6 +1880,15 @@ path = MastodonSDK; sourceTree = ""; }; + DB6F5E36264E78EA009108F4 /* AutoCompletion */ = { + isa = PBXGroup; + children = ( + DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */, + DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */, + ); + path = AutoCompletion; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1894,6 +1909,7 @@ DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( + DB6F5E36264E78EA009108F4 /* AutoCompletion */, DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, @@ -2199,6 +2215,7 @@ isa = PBXGroup; children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, ); @@ -2400,7 +2417,7 @@ 2D939AC725EE14620076FA61 /* CropViewController */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, DBB525072611EAC0002F1F29 /* Tabman */, - DB35B0B22643D821006AC73B /* TwitterTextEditor */, + DB6F5E31264E7410009108F4 /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2589,7 +2606,7 @@ DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, - DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3035,6 +3052,7 @@ 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, + DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, @@ -3068,6 +3086,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DB6F5E35264E78E7009108F4 /* AutoCompletionViewController.swift in Sources */, 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, @@ -3160,6 +3179,7 @@ DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, + DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, @@ -3938,14 +3958,6 @@ minimumVersion = 0.1.1; }; }; - DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/twitter/TwitterTextEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; @@ -3970,6 +3982,14 @@ minimumVersion = 4.2.2; }; }; + DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/TwitterTextEditor.git"; + requirement = { + branch = "feature/expose-layout"; + kind = branch; + }; + }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -4031,11 +4051,6 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; - DB35B0B22643D821006AC73B /* TwitterTextEditor */ = { - isa = XCSwiftPackageProductDependency; - package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; - productName = TwitterTextEditor; - }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -4056,6 +4071,11 @@ package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; + DB6F5E31264E7410009108F4 /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38325ae9..cad5ea61 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -138,11 +138,11 @@ }, { "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/twitter/TwitterTextEditor", + "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor.git", "state": { - "branch": null, - "revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a", - "version": "1.1.0" + "branch": "feature/expose-layout", + "revision": "c208329b23dcb3c8c7192de34776440d625a26a4", + "version": null } }, { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 2b7aecae..33465fc3 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -37,6 +37,7 @@ extension ComposeStatusSection { repliedToCellFrameSubscriber: CurrentValueSubject, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + textEditorViewChangeObserver: TextEditorViewChangeObserver, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, @@ -45,6 +46,7 @@ extension ComposeStatusSection { UICollectionViewDiffableDataSource(collectionView: collectionView) { [ weak customEmojiPickerInputViewModel, weak textEditorViewTextAttributesDelegate, + weak textEditorViewChangeObserver, weak composeStatusAttachmentTableViewCellDelegate, weak composeStatusPollOptionCollectionViewCellDelegate, weak composeStatusNewPollOptionCollectionViewCellDelegate, @@ -92,6 +94,7 @@ extension ComposeStatusSection { } ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate + cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay cell.composeContent .removeDuplicates() .receive(on: DispatchQueue.main) diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 4e1d855b..c7e35c11 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -152,41 +152,4 @@ extension ActiveLabel { return elements } -// public override func accessibilityElementCount() -> Int { -// return 1 + activeEntities.count -// } -// -// public override func accessibilityElement(at index: Int) -> Any? { -// if index == 0 { -// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) -// element.accessibilityTraits = .staticText -// element.accessibilityLabel = accessibilityLabel -// element.accessibilityFrame = superview!.convert(frame, to: nil) -// element.index = index -// return element -// } -// -// let index = index - 1 -// guard index < activeEntities.count else { return nil } -// let eneity = activeEntities[index] -// guard let element = eneity.accessibilityElement(in: self) else { return nil } -// -// var glyphRange = NSRange() -// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) -// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) -// element.accessibilityFrame = self.convert(rect, to: nil) -// element.accessibilityContainer = self -// -// return element -// } -// -// public override func index(ofAccessibilityElement element: Any) -> Int { -// guard let element = element as? ActiveLabelAccessibilityElement, -// let index = element.index else { -// return NSNotFound -// } -// -// return index -// } - } diff --git a/Mastodon/Helper/MastodonRegex.swift b/Mastodon/Helper/MastodonRegex.swift new file mode 100644 index 00000000..43aad4df --- /dev/null +++ b/Mastodon/Helper/MastodonRegex.swift @@ -0,0 +1,20 @@ +// +// MastodonRegex.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import Foundation + +enum MastodonRegex { + /// mention, hashtag. + /// @... + /// #... + static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))" + /// emoji + /// :shortcode: + /// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect + /// precondition :\B with following space + static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))" +} diff --git a/Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift b/Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift new file mode 100644 index 00000000..61f247b4 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift @@ -0,0 +1,48 @@ +// +// AutoCompleteTopChevronView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import UIKit + +final class AutoCompleteTopChevronView: UIView { + + static let chevronSize = CGSize(width: 20, height: 12) + + var chevronMinX: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func _init() { + backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + } + + override func draw(_ rect: CGRect) { + let bezierPath = UIBezierPath() + let bottomY = rect.height + let topY = 0 + let count = Int(ceil(rect.width / CGFloat(SawToothView.widthUint))) + bezierPath.move(to: CGPoint(x: 0, y: bottomY)) + for n in 0 ..< count { + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 0.5) * Double(SawToothView.widthUint)), y: CGFloat(topY))) + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 1) * Double(SawToothView.widthUint)), y: CGFloat(bottomY))) + } + bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) + bezierPath.close() + Asset.Colors.Background.systemBackground.color.setFill() + bezierPath.fill() + bezierPath.lineWidth = 0 + bezierPath.stroke() + } + +} diff --git a/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift b/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift new file mode 100644 index 00000000..aadec1cd --- /dev/null +++ b/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift @@ -0,0 +1,24 @@ +// +// AutoCompletionViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import UIKit + +final class AutoCompletionViewController: UIViewController { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + +} + +extension AutoCompletionViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .red.withAlphaComponent(0.5) + } + +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 5ec2a9ee..dfcb47fe 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -18,6 +18,7 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { let statusContentWarningEditorView = StatusContentWarningEditorView() + let textEditorViewContainerView = UIView() let textEditorView: TextEditorView = { let textEditorView = TextEditorView() textEditorView.font = .preferredFont(forTextStyle: .body) @@ -27,6 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { textEditorView.keyboardType = .twitter return textEditorView }() + + // input + weak var textEditorViewChangeObserver: TextEditorViewChangeObserver? // output let composeContent = PassthroughSubject() @@ -75,13 +79,23 @@ extension ComposeStatusContentCollectionViewCell { statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - textEditorView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(textEditorView) + textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textEditorViewContainerView) NSLayoutConstraint.activate([ - textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10), + textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10), + ]) + textEditorViewContainerView.preservesSuperviewLayoutMargins = true + + textEditorView.translatesAutoresizingMaskIntoConstraints = false + textEditorViewContainerView.addSubview(textEditorView) + NSLayoutConstraint.activate([ + textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), + textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), + textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), + textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) @@ -98,6 +112,10 @@ extension ComposeStatusContentCollectionViewCell { // MARK: - TextEditorViewChangeObserver extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + defer { + textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 2a0d46a1..a738fe21 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -15,6 +15,8 @@ import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { + static let minAutoCompletionVisibleHeight: CGFloat = 100 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -93,6 +95,12 @@ final class ComposeViewController: UIViewController, NeedsDependency { return documentPickerController }() + private(set) lazy var autoCompletionViewController: AutoCompletionViewController = { + let viewController = AutoCompletionViewController() + viewController.context = context + return viewController + }() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -166,6 +174,7 @@ extension ComposeViewController { dependency: self, customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: self, + textEditorViewChangeObserver: self, composeStatusAttachmentTableViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, composeStatusNewPollOptionCollectionViewCellDelegate: self, @@ -181,26 +190,27 @@ extension ComposeViewController { dependency: self ) - // respond scrollView overlap change - //view.layoutIfNeeded() // update layout when keyboard show/dismiss - Publishers.CombineLatest4( - KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), - KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(), - viewModel.isCustomEmojiComposing.eraseToAnyPublisher() + let keyboardEventPublishers = Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow, + KeyboardResponderService.shared.state, + KeyboardResponderService.shared.endFrame ) - .sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in + Publishers.CombineLatest3( + keyboardEventPublishers, + viewModel.isCustomEmojiComposing, + viewModel.autoCompletion + ) + .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompletion in guard let self = self else { return } - + + let (isShow, state, endFrame) = keyboardEvents let extraMargin: CGFloat = { - if self.view.safeAreaInsets.bottom == .zero { - // needs extra margin for zero inset device to workaround UIKit issue - return self.composeToolbarView.frame.height - } else { - // default some magic 16 extra margin - return 16 + var margin = self.composeToolbarView.frame.height + if autoCompletion != nil { + margin += ComposeViewController.minAutoCompletionVisibleHeight } + return margin }() // update keyboard background color @@ -221,27 +231,44 @@ extension ComposeViewController { self.systemKeyboardHeight = endFrame.height let contentFrame = self.view.convert(self.collectionView.frame, to: nil) - let padding = contentFrame.maxY - endFrame.minY + let padding = contentFrame.maxY + extraMargin - endFrame.minY guard padding > 0 else { self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin - UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom - self.view.layoutIfNeeded() - } + self.updateKeyboardBackground(isKeyboardDisplay: false) return } - self.collectionView.contentInset.bottom = padding + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin + self.collectionView.contentInset.bottom = padding + self.collectionView.verticalScrollIndicatorInsets.bottom = padding UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = padding + self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() } self.updateKeyboardBackground(isKeyboardDisplay: isShow) }) .store(in: &disposeBag) + + // bind auto-complete + viewModel.autoCompletion + .receive(on: DispatchQueue.main) + .sink { [weak self] autoCompletion in + guard let self = self else { return } + guard let textEditorView = self.textEditorView() else { return } + if self.autoCompletionViewController.view.superview == nil { + self.autoCompletionViewController.view.frame = self.view.bounds + self.autoCompletionViewController.willMove(toParent: self) + // add to container view. seealso: `viewDidLayoutSubviews()` + textEditorView.superview!.addSubview(self.autoCompletionViewController.view) + self.autoCompletionViewController.didMove(toParent: self) + self.autoCompletionViewController.view.isHidden = true + } + self.autoCompletionViewController.view.isHidden = autoCompletion == nil + guard let autoCompletion = autoCompletion else { return } + self.autoCompletionViewController.view.frame.origin.y = autoCompletion.textBoundingRect.maxY + } + .store(in: &disposeBag) // bind publish bar button state viewModel.isPublishBarButtonItemEnabled @@ -382,6 +409,18 @@ extension ComposeViewController { viewModel.traitCollectionDidChangePublisher.send() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // pin autoCompletionViewController frame to window + if let containerView = autoCompletionViewController.view.superview { + let viewFrameInWindow = containerView.convert(autoCompletionViewController.view.frame, to: nil) + if viewFrameInWindow.origin.x != 0 { + autoCompletionViewController.view.frame.origin.x = -viewFrameInWindow.origin.x + } + } + } + } extension ComposeViewController { @@ -600,10 +639,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))") - // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect - // precondition :\B with following space - let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") + let highlightMatches = string.matches(pattern: MastodonRegex.highlightPattern) + let emojiMatches = string.matches(pattern: MastodonRegex.emojiPattern) // only accept http/https scheme let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") @@ -729,6 +766,97 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } +// MARK: - TextEditorViewChangeObserver +extension ComposeViewController: TextEditorViewChangeObserver { + + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + guard var autoCompeletion = ComposeViewController.scanAutoCompletion(textEditorView: textEditorView) else { + viewModel.autoCompletion.value = nil + return + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString)) + + var glyphRange = NSRange() + textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange) + let textContainer = textEditorView.layoutManager.textContainers[0] + let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + let textBoundingRectInWindow = textEditorView.convert(textBoundingRect, to: nil) + + let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value + guard textBoundingRectInWindow.size != .zero else { + viewModel.autoCompleteRetryLayoutTimes.value += 1 + // avoid infinite loop + guard retryLayoutTimes < 3 else { return } + // needs retry calculate layout when the rect position changing + DispatchQueue.main.async { + self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) + } + return + } + viewModel.autoCompleteRetryLayoutTimes.value = 0 + + // set bounding rect and trigger layout + autoCompeletion.textBoundingRect = textBoundingRect + viewModel.autoCompletion.value = autoCompeletion + } + + struct AutoCompletion { + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + let toHighlightEndString: Substring + + var textBoundingRect: CGRect = .zero + } + + private static func scanAutoCompletion(textEditorView: TextEditorView) -> AutoCompletion? { + let text = textEditorView.text + let cursorLocation = textEditorView.selectedRange.location + let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation) + guard cursorLocation > 0, !text.isEmpty else { return nil } + + let _highlighStartIndex: String.Index? = { + var index = text.index(text.startIndex, offsetBy: cursorLocation - 1) + while index > text.startIndex { + let char = text[index] + if char == "@" || char == "#" { + return index + } + index = text.index(before: index) + } + assert(index == text.startIndex) + let char = text[index] + if char == "@" || char == "#" { + return index + } else { + return nil + } + }() + + guard let highlighStartIndex = _highlighStartIndex else { return nil } + let scanRange = NSRange(highlighStartIndex..= cursorIndex else { return nil } + let toCursorRange = highlighStartIndex.. let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit let repliedToCellFrame = CurrentValueSubject(.zero) + let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) + let autoCompletion = CurrentValueSubject(nil) // output var diffableDataSource: UICollectionViewDiffableDataSource!