diff --git a/MessageViewController.xcodeproj/project.pbxproj b/MessageViewController.xcodeproj/project.pbxproj index fce42cd..626c8e7 100644 --- a/MessageViewController.xcodeproj/project.pbxproj +++ b/MessageViewController.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; }; 38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */; }; 38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */; }; + 6ED7A797206FD89E00B11C0E /* InteractiveTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */; }; + 6ED7A799206FD8F600B11C0E /* InteractiveTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,6 +60,8 @@ 29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+iOS11.swift"; sourceTree = ""; }; 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+ReplaceRange.swift"; sourceTree = ""; }; 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+HighlightingTests.swift"; sourceTree = ""; }; + 6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveTextFormatter.swift; sourceTree = ""; }; + 6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveTextFormatterTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -114,6 +118,7 @@ 2904821E1FED90340053978C /* UITextView+Prefixes.swift */, 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */, 29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */, + 6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */, ); path = MessageViewController; sourceTree = ""; @@ -125,6 +130,7 @@ 29CC293A1FF4266D006B6DE7 /* MessageViewControllerTests.swift */, 29CC29431FF4267F006B6DE7 /* String+WordAtRangeTests.swift */, 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */, + 6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */, ); path = MessageViewControllerTests; sourceTree = ""; @@ -246,6 +252,7 @@ 290482261FED90340053978C /* UIButton+BottomHeightOffset.swift in Sources */, 290482251FED90340053978C /* MessageViewDelegate.swift in Sources */, 290482201FED90340053978C /* MessageViewController.swift in Sources */, + 6ED7A797206FD89E00B11C0E /* InteractiveTextFormatter.swift in Sources */, 29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */, 29792B151FFAE7FC007A0C57 /* MessageAutocompleteController.swift in Sources */, 38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */, @@ -261,6 +268,7 @@ files = ( 29CC293B1FF4266D006B6DE7 /* MessageViewControllerTests.swift in Sources */, 38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */, + 6ED7A799206FD8F600B11C0E /* InteractiveTextFormatterTests.swift in Sources */, 29CC29441FF4267F006B6DE7 /* String+WordAtRangeTests.swift in Sources */, 29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */, ); diff --git a/MessageViewController/InteractiveTextFormatter.swift b/MessageViewController/InteractiveTextFormatter.swift new file mode 100644 index 0000000..7013348 --- /dev/null +++ b/MessageViewController/InteractiveTextFormatter.swift @@ -0,0 +1,44 @@ +// +// InteractiveTextFormatter.swift +// MessageViewController +// +// Created by Viktoras Laukevičius on 31/03/2018. +// Copyright © 2018 Ryan Nystrom. All rights reserved. +// + +import Foundation + +private extension NSAttributedString { + func replacingCharactersWithReplacementsBeginning(in range: NSRange, with: String, attributes: [NSAttributedStringKey : Any]) -> (NSAttributedString, Int) { + let attrStr = self.replacingCharacters(in: range, with: NSAttributedString(string: with, attributes: attributes)) + return (attrStr, range.upperBound + with.count) + } +} + +internal final class InteractiveTextFormatter { + + func applying(change: String, in attrText: NSAttributedString, in range: NSRange) -> (NSAttributedString, Int)? { + // currently supports only if a single (new line) character is typed + guard change == "\n" else { return nil } + + let rangeBegin = attrText.string.index(attrText.string.startIndex, offsetBy: range.lowerBound) + let textUpToChangeLoc = attrText.string.prefix(upTo: rangeBegin) + guard let lastLine = textUpToChangeLoc.components(separatedBy: "\n").last, lastLine.count != 0 else { return nil } + + // it's safe to get attributes of text at specified location because + // not empty components list ensures that the text is not empty + let textAttrs = attrText.attributes(at: range.lowerBound - 1, effectiveRange: nil) + + if let unorderedPrefix = ["* ", "- ", "+ "].first(where: lastLine.starts) { + return attrText.replacingCharactersWithReplacementsBeginning(in: range, with: "\n\(unorderedPrefix)", attributes: textAttrs) + } else if let candidate = lastLine.components(separatedBy: " ").first, candidate.hasSuffix("."), let intVal = Int(candidate.prefix(candidate.count - 1)) { + guard intVal &+ 1 > intVal else { + // wow, someone created a really long list + return nil + } + return attrText.replacingCharactersWithReplacementsBeginning(in: range, with: "\n\(intVal + 1). ", attributes: textAttrs) + } else { + return nil + } + } +} diff --git a/MessageViewController/MessageTextView.swift b/MessageViewController/MessageTextView.swift index 3399d65..0a75596 100644 --- a/MessageViewController/MessageTextView.swift +++ b/MessageViewController/MessageTextView.swift @@ -19,6 +19,8 @@ open class MessageTextView: UITextView, UITextViewDelegate { private var listeners: NSHashTable = NSHashTable.weakObjects() + private let interactiveFormatter = InteractiveTextFormatter() + open override var delegate: UITextViewDelegate? { get { return self } set {} @@ -135,7 +137,15 @@ open class MessageTextView: UITextView, UITextViewDelegate { public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { enumerateListeners { $0.willChangeRange(textView: self, to: range) } - return true + + if let (newAttrText, insertionEnd) = interactiveFormatter.applying(change: text, in: textView.attributedText, in: range) { + textView.attributedText = newAttrText + let selectionPos = textView.position(from: textView.beginningOfDocument, offset: insertionEnd) ?? textView.beginningOfDocument + textView.selectedTextRange = textView.textRange(from: selectionPos, to: selectionPos) + return false + } else { + return true + } } } diff --git a/MessageViewControllerTests/InteractiveTextFormatterTests.swift b/MessageViewControllerTests/InteractiveTextFormatterTests.swift new file mode 100644 index 0000000..a734abc --- /dev/null +++ b/MessageViewControllerTests/InteractiveTextFormatterTests.swift @@ -0,0 +1,100 @@ +// +// InteractiveTextFormatterTests.swift +// MessageViewControllerTests +// +// Created by Viktoras Laukevičius on 31/03/2018. +// Copyright © 2018 Ryan Nystrom. All rights reserved. +// + +import XCTest +@testable import MessageViewController + +private func generateList(withDelimiter d: String) -> String { + return "\(d) Item A\n\(d) Item B" +} + +class InteractiveTextFormatterTests: XCTestCase { + + var formatter: InteractiveTextFormatter! + + override func setUp() { + super.setUp() + formatter = InteractiveTextFormatter() + } + + override func tearDown() { + formatter = nil + super.tearDown() + } + + func test_continuesNumberedList_whenAtTheEnd() { + let str = "1. Item A\n2. Item B" + let attrStr = NSAttributedString(string: str) + let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0 + XCTAssertEqual(text?.string, "\(str)\n3. ") + } + + func test_ignoresIncrement_whenReallyLongList() { + let maxInt = Int.max + let str = "\(maxInt - 1). Item A \n\(maxInt). Item B" + let attrStr = NSAttributedString(string: str) + let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0 + XCTAssertNil(text) + } + + func test_continuesNumberedList_whenInTheMiddle() { + let str1 = "1. Item A \n2. Item B" + let str2 = "\nContinues..." + let attrStr = NSAttributedString(string: "\(str1)\(str2)") + let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str1.count, length: 0))?.0 + XCTAssertEqual(text?.string, "\(str1)\n3. \(str2)") + } + + func test_continuesList_whenUsedAsterisks() { + let str = generateList(withDelimiter: "*") + let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0 + XCTAssertEqual(text?.string, "\(str)\n* ") + } + + func test_continuesList_whenUsedMinuses() { + let str = generateList(withDelimiter: "-") + let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0 + XCTAssertEqual(text?.string, "\(str)\n- ") + } + + func test_continuesList_whenUsedPuses() { + let str = generateList(withDelimiter: "+") + let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0 + XCTAssertEqual(text?.string, "\(str)\n+ ") + } + + func test_preservesAttributes_whenTextPrefilled() { + let str = generateList(withDelimiter: "-") + let attrs: [NSAttributedStringKey : AnyHashable] = [NSAttributedStringKey.baselineOffset : 3] + let attrStr = NSAttributedString(string: str, attributes: attrs) + let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0 + let resultAttrs = text?.attributes(at: str.count + 2, effectiveRange: nil) as? [NSAttributedStringKey : AnyHashable] + XCTAssertEqual(resultAttrs!, attrs) + } + + func test_adjustsSelectionPosition_whenTextAppended() { + let str = generateList(withDelimiter: "-") + let selectionBeginning = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.1 + let appended = "\n- " + XCTAssertEqual(selectionBeginning, str.count + appended.count) + } + + func test_adjustsSelectionPosition_whenTextInserted() { + let str1 = generateList(withDelimiter: "-") + let str2 = "\nContinues..." + let selectionBeginning = formatter.applying(change: "\n", in: NSAttributedString(string: "\(str1)\(str2)"), in: NSRange(location: str1.count, length: 0))?.1 + let inserted = "\n- " + XCTAssertEqual(selectionBeginning, str1.count + inserted.count) + } + + func test_notContinuesList_whenLineNotListItem() { + let str = generateList(withDelimiter: "-") + "\nContinues..." + let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0 + XCTAssertNil(text) + } +}