0

I have a UITextView to let users type-in comments for some audio file they have just recorded.
I would like to limit the amount of the "\n" (newline characters) they can use to 5 (i.e., the comment should be up to 5 lines long). If they try going to the sixth line I would like to show an alert with a sensible message and, upon pressing the OK button in the relative action, I would like to let the user be able to edit their text.

Delegation is already set up and implementation of the optional func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool is what I am doing now.
The logic I put inside shows the alert correctly but then, upon clicking OK and trying to delete some characters, the method is called again and I get the alert.
I know this is due to the counter still being stuck at 5 but resetting it to 0 allows then for 9 lines or more, so it is not a solution.

Here is the code that I tried and that is not working as intended:

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    if text == characterToCheck /* \n, declared globally */ {
        characterCounter += 1 // this was also declared as a global property
    }

    if characterCounter > 4 {
        let newlineAC = UIAlertController(title: "Too many linebreaks", message: "Please go back and make your comment fit into a maximum of 5 lines", preferredStyle: .alert)
        newlineAC.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] (_) in
            let currentText = textView.text ?? ""
            guard let currentTextRange = Range(range, in: currentText) else { return }

            self?.comments.text = currentText.replacingOccurrences(of: "\n", with: "@ ", range: currentTextRange)
        })
        present(newlineAC, animated: true)

        return false
    } else {
        return true
    }
}  

No error message is thrown because the code does exactly what I ask him but I'm clearly asking it in the wrong way. What can I do?

NotationMaster
  • 390
  • 3
  • 17
  • One other thing to consider. How do you want to handle text that contains no newlines but still takes up more than 5 lines in the text view? – rmaddy Jul 19 '19 at 16:40
  • Note that your code assumes the user is typing in one character at a time. Your code doesn't handle a user pasting in a bunch of text. – rmaddy Jul 19 '19 at 16:41
  • By now I would allow that and, possibly, go into the table view that is displaying the content and limit the number of lines to show to 5 or so. For the handling of pasting the text I have no idea how to handle that but it could be an idea. As thorough a solution as possible would be much appreciated. – NotationMaster Jul 19 '19 at 16:42

2 Answers2

2

Here is my solution:

UPDATED WITH Matt Bart feedback

 func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    if text == String(characterToCheck) /* \n, declared globally */ {
        characterCounter += 1 // this was also declared as a global property
    }

    if text == "" {
        let characters = Array(textView.text)
        if characters.count >= range.location {
            let deletedCharacter = characters[range.location]
            if  deletedCharacter == characterToCheck {
                characterCounter -= 1
            }
        }
    }

    if characterCounter > 4 {
        let newlineAC = UIAlertController(title: "Too many linebreaks", message: "Please go back and make your comment fit into a maximum of 5 lines", preferredStyle: .alert)
        newlineAC.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] (_) in
            let currentText = textView.text ?? ""
            guard let currentTextRange = Range(range, in: currentText) else { return }

            self?.comments.text = currentText.replacingOccurrences(of: "\n", with: "@ ", range: currentTextRange)
        })
        present(newlineAC, animated: true, completion: { [weak self] in
            self?.characterCounter -= 1
        })

        return false
    } else {
        return true
    }
}

}

Also, declare characterToCheck as a Character instead of String

characterCounter must start at 0

Rey Bruno
  • 355
  • 1
  • 13
  • Thank you! This solved the issue! Now, how could I improve this so that also pasted text is taken into consideration? – NotationMaster Jul 19 '19 at 19:19
  • 1
    This assumes that the thing that they are deleting is the last element in the array. Which is not always the case :) – Matt Bart Jul 19 '19 at 19:26
  • You should also take into consider the range variable – Matt Bart Jul 19 '19 at 19:27
  • Matt Barr is right, i don't have my mac here, but when I have it, I will improve the answer. – Rey Bruno Jul 19 '19 at 19:30
  • 1
    Yes closer, but this will still not account for when a user selects and deletes text all at once, rather than backspacing single characters, but with some tweaking from Neera it should work. – Matt Bart Jul 19 '19 at 19:38
  • Thank you @MattBart! How would you implement that, if I may ask? – NotationMaster Jul 20 '19 at 06:49
  • I will need to be at my computer to do some testing. Won’t be there till tomorrow. However I would test with the debugger the replaceText (should be “”) and the range, when deleting a portion of text at once. I would also do a similar experiment to see what happens when a users pastes content. – Matt Bart Jul 20 '19 at 12:59
0

The logic you are describing is happening because you are always incrementing the counter, but never decrementing it. You should be decrementing the counter, if you incremented the counter, but never ended up adding the new line to the TextView.

...
present(newlineAC, animated: true)
characterCounter-=1
return false
...

Why you should decrement:

By returning false in this function, the TextView will not add the new changes to the TextView. Because they are not added, it shouldn’t be counted in characterCount.

Selection Delete

You must also take into consideration when a user deletes a whole selection of text at once.

let text = NSString(string: textView.text)
for char in text.substring(with: range) {
    print(char)
if char == characterToCheck {
        //if the text that is going to be removed has the character
        //remove it from the count      
        characterCounter-=1
    }
}

Make sure if the user if deleting, the function will return true so that the text actually gets deleted.

Paste (Selection Insert)

If a user pastes, a bunch of text we need to check the line breaks there.

for char in replacementString {
    if char == characterToCheck {
      characterCounter+=1
  } 
}

All Together Now

This takes everything into account. Updates a little bit of logic with a new variable as well.

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    var localCount = characterCount
    //check for deletion
    let NStext = NSString(string: textView.text)
    for char in NStext.substring(with: range) {
        print(char)
        if char == characterToCheck {
            //if the text that is going to be removed has the character
            //remove it from the count      
            localCount-=1
        }
    }

    //check for insertion
    //this will also replace the simple check in the beginning
    for char in replacementString {
            if char == characterToCheck {
            //if any of the character being inserted is the one
            //will be taken into account here
                localCount+=1
            } 
        }

    if localCount > 4 {
        let newlineAC = UIAlertController(title: "Too many linebreaks", message: "Please go back and make your comment fit into a maximum of 5 lines", preferredStyle: .alert)
        newlineAC.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] (_) in
            let currentText = textView.text ?? ""
            guard let currentTextRange = Range(range, in: currentText) else { return }

            self?.comments.text = currentText.replacingOccurrences(of: "\n", with: "@ ", range: currentTextRange)
        })
        present(newlineAC, animated: true)
        return false
    } else {
        characterCounter = localCount
        //only updates if has an OK # of newlines
        return true
    }
}  
Matt Bart
  • 809
  • 1
  • 7
  • 26
  • Your approach is an improvement but not a solution yet because if I then go back erasing one line and try to add a new line the counter being at 4 will trigger 5 even if I then have only 3 newline characters. How to solve this? I would like to avoid looping over the text every time to see how many \n are there. – NotationMaster Jul 19 '19 at 17:26
  • I see what you are saying, will update when I get a chance to take into account deleting. When deleting the replacementText will be “” and the range will be the range that it is replacing. If there are any newline within that range, you should decrement them from characterCounter and then return false (so that the deleting processes properly). – Matt Bart Jul 19 '19 at 18:00
  • Nice, but how to tell the system I am deleting? Thank you for your effort. I'll wait for your proposed solution. My head still needs to wrap around the logic of this method, what it actually does and so on. Its Documentation says that if the user hits delete its range is 1 – NotationMaster Jul 19 '19 at 18:07
  • This is very confusing to me: why declare a `text` property with the same name as the parameter name? Deletion should have `text = 0` somewhere and I don’t see it in the first paragraph. Then, what is `replacementString`? Where have you declared it? – NotationMaster Jul 22 '19 at 16:48
  • 1
    @NotationMaster Let me correct that, I was doing each part separately (and then putting them together) :) – Matt Bart Jul 22 '19 at 16:51