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