feat: [WIP] add auto completion view for compose highlight

This commit is contained in:
CMK 2021-05-14 20:02:59 +08:00
parent 761d094832
commit c2c38c9307
11 changed files with 321 additions and 93 deletions

View File

@ -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 = "<group>"; };
DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = "<group>"; };
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompletionViewController.swift; sourceTree = "<group>"; };
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
@ -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 = "<group>";
};
DB6F5E36264E78EA009108F4 /* AutoCompletion */ = {
isa = PBXGroup;
children = (
DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */,
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */,
);
path = AutoCompletion;
sourceTree = "<group>";
};
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" */;

View File

@ -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
}
},
{

View File

@ -37,6 +37,7 @@ extension ComposeStatusSection {
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
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)

View File

@ -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
// }
}

View File

@ -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)))"
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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<String, Never>()
@ -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)

View File

@ -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<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
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..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.highlightPattern, options: [], range: scanRange) else { return nil }
let matchRange = match.range(at: 0)
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
let toCursorRange = highlighStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let autoCompletion = AutoCompletion(
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompletion
}
}
// MARK: - ComposeToolbarViewDelegate
extension ComposeViewController: ComposeToolbarViewDelegate {

View File

@ -17,6 +17,7 @@ extension ComposeViewModel {
dependency: NeedsDependency,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: TextEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
@ -30,6 +31,7 @@ extension ComposeViewModel {
repliedToCellFrameSubscriber: repliedToCellFrame,
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: textEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,

View File

@ -31,6 +31,8 @@ final class ComposeViewModel {
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make intial event emit
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
let autoCompletion = CurrentValueSubject<ComposeViewController.AutoCompletion?, Never>(nil)
// output
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!