Search code examples
swiftswiftuiutf-8character-encodingtextfield

UTF-8 character limit on TextField not working properly


I have a text field in my app that needs its character count limited. It works fine with regular text. But I need to take into account special characters and emojis and when the textfield reaches the limit, and I delete one character, and input for example ¢ (2 bytes) or € (3 bytes) repeatedly, it behaves as if there is no character limit at all and you can input anything.

TextField("Username", text: $ame)
    .onChange(of: name, perform: { value in
        totalBytes = value.utf8.count
        // Only mess with the value if it is too big
        if totalBytes > 14 {
            let firstNBytes = Data(name.utf8.prefix(14))
            if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
                // Set the message back to the last place where it was the right size
                name = maxBytesString
            } else {
                print("not a valid UTF-8 sequence")
            }
        }
    })

The console does print the "not valid UTF-8" warning, but the textfield is never corrected in such an edge case. Any help? BTW, "name" is a @State variable and gets initialized successfully with the onAppear method, if that matters.


Solution

  • The issue is with let firstNBytes = Data(name.utf8.prefix(14)). As you've seen, some characters are more than 1 byte. When you simply take the 1st 14 bytes you can easily be cutting a character in half. This is why the attempt to create a String from the truncated bytes can fail.

    You need a way to trim the data byte by byte, starting at 14, until you have valid data. You may end up with a string that is only 13, 12, 11, or even fewer bytes, but you at least have a string that is less than 14 bytes.

    Here is an extension to String that will return a truncated string based on a supplied byte limit:

    extension String {
        func truncated(to bytes: Int, encoding: String.Encoding = .utf8) -> String? {
            if let data = self.data(using: encoding) {
                var tmpData = data.prefix(bytes)
                repeat {
                    if let res = String(data: tmpData, encoding: encoding) {
                        return res
                    } else {
                        tmpData = tmpData.dropLast(1)
                    }
                } while !tmpData.isEmpty
            }
    
            return nil // Should never be reached with UTF-8 encoding
        }
    }
    

    With that your code becomes something like the following:

    TextField("Username", text: $name)
        .onChange(of: name, perform: { value in
            totalBytes = value.utf8.count
            // Only mess with the value if it is too big
            if totalBytes > 14 {
                if let newName = value.truncated(to: 14) {
                    name = newName
                }
            }
        })