0

I wrote this mid method in Extension.

func mid(fromIndex: Int, toIndex: Int) -> String {

    var _fromIndex = 0

    if( fromIndex >= self.count ) {
        return ""
    }
    else if( fromIndex > 0 ){
        _fromIndex = fromIndex
    }

    var _toIndex = 0

    if( toIndex >= self.count ) {
        _toIndex = self.count - 1
    }
    else if( toIndex > 0 ){
        _toIndex = toIndex
    }

    if( _fromIndex > _toIndex ){
        return ""
    }

    return String(self[self.index(self.startIndex, offsetBy: _fromIndex)...self.index(self.startIndex, offsetBy: _toIndex)])
}

And use this method to retrieve user deleted string.

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    defer{
        if text.isEmpty {
            let deletedText = textView.attributedText.string.mid(fromIndex: range.lowerBound, toIndex: range.upperBound)
        }
    }
}

But it sometimes throws this exception.

Fatal Exception: NSRangeException *** -[NSBigMutableString characterAtIndex:]: Index 672 out of bounds; string length 672 specialized String.subscript.getter

I already check the size of the string before using String.subscript.getter.

if( toIndex >= self.count ) {
    _toIndex = self.count - 1
}

And the text operation should be on the main thread. Why am I still getting this exception? Did I misunderstood the string index in Swift?

UPDATE1

According to @Wattholm's answer. I'm using this way to get text with NSRange from UITextView.

let contents: String = textView.text

if let _range = Range(range, in: contents) {
    let deletedText = String(contents[_range])
}
else {
    logHelper.w("Failed to get deletedText from NSRange, try UITextRange.")

    if let startPosition = textView.position(from: textView.beginningOfDocument, offset: range.location),
       let endPosition = textView.position(from: startPosition, offset: range.length),
       let textRange = textView.textRange(from: startPosition, to: endPosition), 
       let deletedText = textView.text(in: textRange) {

        // do something with deletedText
    }
    else {
        logHelper.w("Failed to get deletedText from UITextRange, try NSString.")

        let deletedText = (contents as NSString).mid(fromIndex: range.lowerBound, toIndex: range.upperBound)
    }
}

But it still fall into the second else and throwing NSRangeException. Which means the length and the index still doesn't match each other.

UPDATE2

Tried to use NSString.substring but still keep getting this exception.

let deletedText = (contents as NSString).substring(with: range)

Maybe there's a bug in UITextView?

12
  • deletedText = contents.mid() - did you mean textView.text instead of contents? Commented Oct 12, 2019 at 12:30
  • Also note the case where toIndex is self.count + 17 for example. Commented Oct 12, 2019 at 12:31
  • Swifter extensions? Commented Oct 12, 2019 at 13:25
  • @Yonat Sorry, I forgot to mention that the contents is from textView.attributedText.string. Commented Oct 13, 2019 at 13:29
  • Is it possible for you to give the string text in the textView when the error occurs, so I can replicate the error? And also I would like to know what the user does that triggers the call to ShouldChangeTextIn right before the NSRangeException occurs? Might also if you could log some of the data to the console such as the lower bound, upper bound, contents, deletedText at that point. Commented Oct 25, 2019 at 7:18

2 Answers 2

2

I could be wrong but I think the issue here relates to the inherent differences between String and NSString, or between Range and NSRange. A String's count will not always be the same as an NSString's length.

There might be an easier way to do it, but what I attempted was to create an extension for NSString in the same way that you created one for String, and then cast 'contents' (which I assume is a String; there seems to be some missing code here since 'contents' was not declared anywhere) to NSString so that it would work correctly, since the textview supplies us with the NSRange:

extension NSString {
    func mid(fromIndex: Int, toIndex: Int) -> NSString {

        var _fromIndex = 0

        if( fromIndex >= self.length ) {
            return ""
        }
        else if( fromIndex > 0 ){
            _fromIndex = fromIndex
        }

        var _toIndex = 0

        if( toIndex >= self.length ) {
            _toIndex = self.length - 1
        }
        else if( toIndex > 0 ){
            _toIndex = toIndex
        }

        if( _fromIndex > _toIndex ){
            return ""
        }

        return NSString(string: self.substring(with: NSRange(location: _fromIndex, length: _toIndex - _fromIndex + 1)))
    }
}

And for the textview:

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    defer{
        if text.isEmpty {
            let deletedText = (contents as NSString).mid(fromIndex: range.lowerBound, toIndex: range.upperBound - 1)
        }
    }
}

The other way to do this is to convert the NSRange into a Range, which is probably the better way, especially if you require that the count of characters be exactly the way a String counts its characters in Swift. The range: NSRange in the textview function can be converted to a Range like this:

let swiftRange = Range(range, in: rangeText)

where rangeText should be replaced by the text property that the range is referring to.

Sign up to request clarification or add additional context in comments.

1 Comment

The contents is from textView.attributedText.string. I've edited the code snippet.
0

This is a swiftier version which takes advantage of the reliable conversion from NSRange to Range<String.Index> and vice versa.

extension String {

    func mid(fromIndex: Int, toIndex: Int) -> String {
        guard toIndex >= fromIndex else  { return "" }
        let nsRange = NSRange(location: fromIndex, length: toIndex + 1 - fromIndex)
        guard let range = Range(nsRange, in: self) else { return "" }
        return String(self[range])
    }
}

Actually you don't need the extension as you already got an NSRange, just write

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    defer{
        if text.isEmpty {
            let textViewString = textView.attributedText.string
            guard let swiftRange = Range(range, in: textViewString) else { return }
            let deletedText = String(textViewString[swiftRange])
        }
    }
}

1 Comment

I already use Range(range, in: textViewString) in my app. You can see this line in UPDATE1. But it still fall into the second else. Maybe the range is not matching the contents already?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.