diff --git a/platforms/ios/example/Wysiwyg/Views/ContentView.swift b/platforms/ios/example/Wysiwyg/Views/ContentView.swift
index 5199d9b87..79add4ef5 100644
--- a/platforms/ios/example/Wysiwyg/Views/ContentView.swift
+++ b/platforms/ios/example/Wysiwyg/Views/ContentView.swift
@@ -79,7 +79,7 @@ struct ContentView: View {
}
HStack {
Spacer()
- if viewModel.textView.autocorrectionType == .yes {
+ if viewModel.textView?.autocorrectionType == .yes {
Image(systemName: "text.badge.checkmark")
.foregroundColor(.green)
.padding(.horizontal, 16)
diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift
index cef615691..19164f30d 100644
--- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift
+++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift
@@ -85,7 +85,7 @@ public struct WysiwygComposerView: View {
@ViewBuilder
private var placeholderView: some View {
- if viewModel.isContentEmpty, !viewModel.textView.isDictationRunning {
+ if viewModel.isContentEmpty, viewModel.textView?.isDictationRunning != true {
Text(placeholder)
.font(Font(UIFont.preferredFont(forTextStyle: .body)))
.foregroundColor(placeholderColor)
@@ -121,8 +121,15 @@ struct UITextViewWrapper: UIViewRepresentable {
}
func makeUIView(context: Context) -> WysiwygTextView {
- let textView = viewModel.textView
-
+ // Default text container have a slightly different behaviour
+ // than what iOS would use if textContainer is nil, this
+ // fixes issues with background color not working on newline characters.
+ let layoutManager = NSLayoutManager()
+ let textStorage = NSTextStorage()
+ let textContainer = NSTextContainer()
+ textStorage.addLayoutManager(layoutManager)
+ layoutManager.addTextContainer(textContainer)
+ let textView = WysiwygTextView(frame: .zero, textContainer: textContainer)
textView.accessibilityIdentifier = "WysiwygComposer"
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.autocapitalizationType = .sentences
@@ -137,12 +144,16 @@ struct UITextViewWrapper: UIViewRepresentable {
textView.clipsToBounds = false
textView.tintColor = UIColor.tintColor
textView.wysiwygDelegate = context.coordinator
+ viewModel.textView = textView
viewModel.updateCompressedHeightIfNeeded()
-
return textView
}
- func updateUIView(_ uiView: WysiwygTextView, context: Context) { }
+ func updateUIView(_ uiView: WysiwygTextView, context: Context) {
+ if uiView !== viewModel.textView {
+ viewModel.textView = uiView
+ }
+ }
func makeCoordinator() -> Coordinator {
Coordinator(viewModel.replaceText,
diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift
index de9c667b2..84133104f 100644
--- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift
+++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift
@@ -26,19 +26,17 @@ import UIKit
public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, ObservableObject {
// MARK: - Public
- /// The textView that the model manages.
- public private(set) var textView = {
- // Default text container have a slightly different behaviour
- // than what iOS would use if textContainer is nil, this
- // fixes issues with background color not working on nealine characters.
- let layoutManager = NSLayoutManager()
- let textStorage = NSTextStorage()
- let textContainer = NSTextContainer()
- textStorage.addLayoutManager(layoutManager)
- layoutManager.addTextContainer(textContainer)
- return WysiwygTextView(frame: .zero, textContainer: textContainer)
- }()
-
+ /// The textView that the model currently manages.
+ public var textView: WysiwygTextView? {
+ didSet {
+ guard let textView else {
+ return
+ }
+ textView.linkTextAttributes[.foregroundColor] = parserStyle.linkColor
+ textView.apply(attributedContent)
+ }
+ }
+
/// The composer minimal height.
public let minHeight: CGFloat
/// The mention replacer defined by the hosting application.
@@ -79,7 +77,7 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa
public var parserStyle: HTMLParserStyle {
didSet {
// In case of a color change, this will refresh the attributed text
- textView.linkTextAttributes[.foregroundColor] = parserStyle.linkColor
+ textView?.linkTextAttributes[.foregroundColor] = parserStyle.linkColor
let update = model.setContentFromHtml(html: content.html)
applyUpdate(update)
updateTextView()
@@ -141,7 +139,6 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa
self.parserStyle = parserStyle
self.mentionReplacer = mentionReplacer
- textView.linkTextAttributes[.foregroundColor] = parserStyle.linkColor
model = ComposerModelWrapper()
model.delegate = self
// Publish composer empty state.
@@ -156,7 +153,10 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa
guard let self = self else { return }
// Improves a lot the user experience by keeping the selected range always visible when there are changes in the size.
DispatchQueue.main.async {
- self.textView.scrollRangeToVisible(self.textView.selectedRange)
+ guard let textView = self.textView else {
+ return
+ }
+ textView.scrollRangeToVisible(textView.selectedRange)
}
}
.store(in: &cancellables)
@@ -221,7 +221,7 @@ public extension WysiwygComposerViewModel {
/// Clear the content of the composer.
func clearContent() {
if plainTextMode {
- textView.attributedText = NSAttributedString(string: "", attributes: defaultTextAttributes)
+ textView?.attributedText = NSAttributedString(string: "", attributes: defaultTextAttributes)
updateCompressedHeightIfNeeded()
} else {
applyUpdate(model.clear())
@@ -283,6 +283,10 @@ public extension WysiwygComposerViewModel {
public extension WysiwygComposerViewModel {
func updateCompressedHeightIfNeeded() {
+ guard let textView else {
+ return
+ }
+
let idealTextHeight = textView
.sizeThatFits(CGSize(width: textView.bounds.size.width,
height: CGFloat.greatestFiniteMagnitude)
@@ -303,8 +307,8 @@ public extension WysiwygComposerViewModel {
// This fixes a bug where the attributed string keeps link and inline code formatting
// when they are the last formatting to be deleted
- if textView.attributedText.length == 0 {
- textView.typingAttributes = defaultTextAttributes
+ if textView?.attributedText.length == 0 {
+ textView?.typingAttributes = defaultTextAttributes
}
let update: ComposerUpdate
@@ -332,7 +336,7 @@ public extension WysiwygComposerViewModel {
case let .update(newState):
if newState[.link] != actionStates[.link] {
applyUpdate(update, skipTextViewUpdate: true)
- textView.apply(attributedContent)
+ textView?.apply(attributedContent)
updateCompressedHeightIfNeeded()
return false
}
@@ -346,7 +350,7 @@ public extension WysiwygComposerViewModel {
func select(range: NSRange) {
do {
- guard let text = textView.attributedText, !plainTextMode else { return }
+ guard let text = textView?.attributedText, !plainTextMode else { return }
let htmlSelection = try text.htmlRange(from: range)
Logger.viewModel.logDebug(["Sel(att): \(range)",
"Sel: \(htmlSelection)",
@@ -365,15 +369,17 @@ public extension WysiwygComposerViewModel {
func didUpdateText() {
if plainTextMode {
- if textView.text.isEmpty != isContentEmpty {
- isContentEmpty = textView.text.isEmpty
+ if let textView {
+ if textView.text.isEmpty != isContentEmpty {
+ isContentEmpty = textView.text.isEmpty
+ }
+ plainTextContent = textView.attributedText
}
- plainTextContent = textView.attributedText
} else {
reconciliateIfNeeded()
applyPendingFormatsIfNeeded()
}
-
+
updateCompressedHeightIfNeeded()
}
@@ -405,7 +411,7 @@ public extension WysiwygComposerViewModel {
@available(iOS 16.0, *)
func getIdealSize(_ proposal: ProposedViewSize) -> CGSize {
- guard let width = proposal.width else { return .zero }
+ guard let textView, let width = proposal.width else { return .zero }
let idealHeight = textView
.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
@@ -437,7 +443,7 @@ private extension WysiwygComposerViewModel {
// Note: this makes replaceAll act like .keep on cases where we expect the text
// view to be properly updated by the system.
if !skipTextViewUpdate {
- textView.apply(attributedContent)
+ textView?.apply(attributedContent)
updateCompressedHeightIfNeeded()
}
case let .select(startUtf16Codeunit: start,
@@ -543,7 +549,7 @@ private extension WysiwygComposerViewModel {
if let mentionReplacer {
attributed = mentionReplacer.postProcessMarkdown(in: attributed)
}
- textView.attributedText = attributed
+ textView?.attributedText = attributed
updateCompressedHeightIfNeeded()
} else {
let update = model.setContentFromMarkdown(markdown: computeMarkdownContent())
@@ -556,7 +562,7 @@ private extension WysiwygComposerViewModel {
/// Reconciliate the content of the model with the content of the text view.
func reconciliateIfNeeded() {
do {
- guard !textView.isDictationRunning,
+ guard let textView, !textView.isDictationRunning,
let replacement = try StringDiffer.replacement(from: attributedContent.text.htmlChars,
to: textView.attributedText.htmlChars) else {
return
@@ -580,7 +586,7 @@ private extension WysiwygComposerViewModel {
case StringDifferError.tooComplicated,
StringDifferError.insertionsDontMatchRemovals:
// Restore from the model, as otherwise the composer will enter a broken state
- textView.apply(attributedContent)
+ textView?.apply(attributedContent)
updateCompressedHeightIfNeeded()
Logger.viewModel.logError(["Reconciliate failed, content has been restored from the model"],
functionName: #function)
@@ -600,7 +606,7 @@ private extension WysiwygComposerViewModel {
func applyPendingFormatsIfNeeded() {
guard hasPendingFormats else { return }
- textView.apply(attributedContent)
+ textView?.apply(attributedContent)
updateCompressedHeightIfNeeded()
hasPendingFormats = false
}
@@ -609,6 +615,9 @@ private extension WysiwygComposerViewModel {
///
/// - Returns: A markdown string.
func computeMarkdownContent() -> String {
+ guard let textView else {
+ return ""
+ }
let markdownContent: String
if let mentionReplacer, let attributedText = textView.attributedText {
// `MentionReplacer` should restore altered content to valid markdown.
diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift
index 0f435eef4..ef90b360a 100644
--- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift
+++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift
@@ -19,7 +19,7 @@ import UIKit
public protocol WysiwygComposerViewModelProtocol: AnyObject {
/// The textView that the model manages.
- var textView: WysiwygTextView { get }
+ var textView: WysiwygTextView? { get set }
/// Whether the current content of the composer is empty.
var isContentEmpty: Bool { get }
diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Autocorrection.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Autocorrection.swift
index 544938b28..865c075c5 100644
--- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Autocorrection.swift
+++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Autocorrection.swift
@@ -103,10 +103,10 @@ extension WysiwygComposerViewModelTests {
private extension WysiwygComposerViewModelTests {
func assertAutoCorrectEnabled() {
- XCTAssertEqual(viewModel.textView.autocorrectionType, .yes)
+ XCTAssertEqual(viewModel.textView?.autocorrectionType, .yes)
}
func assertAutocorrectDisabled() {
- XCTAssertEqual(viewModel.textView.autocorrectionType, .no)
+ XCTAssertEqual(viewModel.textView?.autocorrectionType, .no)
}
}
diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+MentionsState.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+MentionsState.swift
index 4eb6571b5..c076eec0f 100644
--- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+MentionsState.swift
+++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+MentionsState.swift
@@ -60,7 +60,11 @@ extension WysiwygComposerViewModelTests {
func testMentionsStatBySettingRoomIDMention() {
viewModel.setMention(url: "https://matrix.to/#/!room:matrix.org", name: "Room", mentionType: .room)
- XCTAssertEqual(viewModel.getMentionsState(), MentionsState(userIds: [], roomIds: ["!room:matrix.org"], roomAliases: [], hasAtRoomMention: false))
+ XCTAssertEqual(viewModel.getMentionsState(),
+ MentionsState(userIds: [],
+ roomIds: ["!room:matrix.org"],
+ roomAliases: [],
+ hasAtRoomMention: false))
}
func testMentionsStateBySettingRoomIDMentionFromContent() {
diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift
index 4aee4e91b..024d6d3f3 100644
--- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift
+++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift
@@ -22,7 +22,14 @@ final class WysiwygComposerViewModelTests: XCTestCase {
var viewModel: WysiwygComposerViewModel!
override func setUpWithError() throws {
+ let layoutManager = NSLayoutManager()
+ let textStorage = NSTextStorage()
+ let textContainer = NSTextContainer()
+ textStorage.addLayoutManager(layoutManager)
+ layoutManager.addTextContainer(textContainer)
+ let textView = WysiwygTextView(frame: .zero, textContainer: textContainer)
viewModel = WysiwygComposerViewModel()
+ viewModel.textView = textView
viewModel.clearContent()
}
@@ -32,13 +39,13 @@ final class WysiwygComposerViewModelTests: XCTestCase {
let expectFalse = expectContentEmpty(false)
_ = viewModel.replaceText(range: .zero,
replacementText: "Test")
- viewModel.textView.attributedText = viewModel.attributedContent.text
+ viewModel.textView?.attributedText = viewModel.attributedContent.text
waitExpectation(expectation: expectFalse, timeout: 2.0)
let expectTrue = expectContentEmpty(true)
_ = viewModel.replaceText(range: .init(location: 0, length: viewModel.attributedContent.text.length),
replacementText: "")
- viewModel.textView.attributedText = viewModel.attributedContent.text
+ viewModel.textView?.attributedText = viewModel.attributedContent.text
waitExpectation(expectation: expectTrue, timeout: 2.0)
}
@@ -88,15 +95,15 @@ final class WysiwygComposerViewModelTests: XCTestCase {
func testReconciliateRestoresFromModel() {
_ = viewModel.replaceText(range: .zero, replacementText: "Some text")
- viewModel.textView.attributedText = NSAttributedString(string: "Some text")
+ viewModel.textView?.attributedText = NSAttributedString(string: "Some text")
reconciliate(to: "Home test", selectedRange: .zero)
- XCTAssertEqual(viewModel.textView.text, "Some text")
+ XCTAssertEqual(viewModel.textView?.text, "Some text")
}
func testPlainTextMode() {
_ = viewModel.replaceText(range: .zero,
replacementText: "Some bold text")
- viewModel.textView.attributedText = NSAttributedString(string: "Some bold text")
+ viewModel.textView?.attributedText = NSAttributedString(string: "Some bold text")
viewModel.select(range: .init(location: 10, length: 4))
viewModel.apply(.bold)
@@ -115,7 +122,7 @@ final class WysiwygComposerViewModelTests: XCTestCase {
let result = viewModel.replaceText(range: .init(location: 4, length: 0), replacementText: "abc")
XCTAssertFalse(result)
XCTAssertEqual(viewModel.content.html, "testabc")
- XCTAssertTrue(viewModel.textView.attributedText.isEqual(to: viewModel.attributedContent.text))
+ XCTAssertTrue(viewModel.textView?.attributedText.isEqual(to: viewModel.attributedContent.text) == true)
}
func testReplaceTextPartiallyInsideAndAfterLinkIsNotAccepted() {
@@ -123,7 +130,7 @@ final class WysiwygComposerViewModelTests: XCTestCase {
let result = viewModel.replaceText(range: .init(location: 3, length: 1), replacementText: "abc")
XCTAssertFalse(result)
XCTAssertEqual(viewModel.content.html, "tesabc")
- XCTAssertTrue(viewModel.textView.attributedText.isEqual(to: viewModel.attributedContent.text))
+ XCTAssertTrue(viewModel.textView?.attributedText.isEqual(to: viewModel.attributedContent.text) == true)
}
func testReplaceTextInsideLinkIsAccepted() {
@@ -148,11 +155,14 @@ final class WysiwygComposerViewModelTests: XCTestCase {
// Enter
mockTrailingTyping("\n")
mockTrailingTyping("Still formatted")
+ guard let textView = viewModel.textView else {
+ XCTFail("TextView should not be nil")
+ return
+ }
XCTAssertTrue(
- viewModel
- .textView
+ textView
.attributedText
- .fontSymbolicTraits(at: viewModel.textView.attributedText.length - 1)
+ .fontSymbolicTraits(at: textView.attributedText.length - 1)
.contains([.traitBold, .traitItalic])
)
}
@@ -242,7 +252,8 @@ extension WysiwygComposerViewModelTests {
/// - text: text to type
/// - location: index in text view's attributed string
func mockTyping(_ text: String, at location: Int) {
- guard location <= viewModel.textView.attributedText.length else {
+ guard let textView = viewModel.textView,
+ location <= textView.attributedText.length else {
fatalError("Invalid location index")
}
@@ -250,7 +261,7 @@ extension WysiwygComposerViewModelTests {
let shouldAcceptChange = viewModel.replaceText(range: range, replacementText: text)
if shouldAcceptChange {
// Force apply since the text view should've updated by itself
- viewModel.textView.apply(viewModel.attributedContent)
+ viewModel.textView?.apply(viewModel.attributedContent)
viewModel.didUpdateText()
}
}
@@ -259,14 +270,18 @@ extension WysiwygComposerViewModelTests {
///
/// - Parameter text: text to type
func mockTrailingTyping(_ text: String) {
- mockTyping(text, at: viewModel.textView.attributedText.length)
+ guard let textView = viewModel.textView else {
+ return
+ }
+ mockTyping(text, at: textView.attributedText.length)
}
/// Mock backspacing at given location.
///
/// - Parameter location: index in text view's attributed string
func mockBackspace(at location: Int) {
- guard location <= viewModel.textView.attributedText.length else {
+ guard let textView = viewModel.textView,
+ location <= textView.attributedText.length else {
fatalError("Invalid location index")
}
@@ -274,14 +289,17 @@ extension WysiwygComposerViewModelTests {
let shouldAcceptChange = viewModel.replaceText(range: range, replacementText: "")
if shouldAcceptChange {
// Force apply since the text view should've updated by itself
- viewModel.textView.apply(viewModel.attributedContent)
+ viewModel.textView?.apply(viewModel.attributedContent)
viewModel.didUpdateText()
}
}
/// Mock backspacing from trailing position.
func mockTrailingBackspace() {
- mockBackspace(at: viewModel.textView.attributedText.length)
+ guard let textView = viewModel.textView else {
+ return
+ }
+ mockBackspace(at: textView.attributedText.length)
}
}
@@ -292,9 +310,9 @@ private extension WysiwygComposerViewModelTests {
/// - newText: New text to apply.
/// - selectedRange: Simulated selection in the text view.
func reconciliate(to newText: String, selectedRange: NSRange) {
- viewModel.textView.attributedText = NSAttributedString(string: newText)
+ viewModel.textView?.attributedText = NSAttributedString(string: newText)
// Set selection where we want it, as setting the content automatically moves cursor to the end.
- viewModel.textView.selectedRange = selectedRange
+ viewModel.textView?.selectedRange = selectedRange
viewModel.didUpdateText()
}
}