swiftuiviewmodifier

SwiftUI: ViewModifier doesn't update when character limit is changed dynamically from outside


I am working on a SwiftUI project where I need to limit the number of characters a user can input in a TextField. I have implemented a custom ViewModifier to enforce this character limit. The limit is passed from outside the view (e.g., from a parent view), and the TextField should dynamically adjust when this limit changes.

However, while everything works as expected the first time ( with the first characterLimit), it doesn't when the characterLimit is updated. the characterLimit in the modifier stays the same as it was initially.

Here’s the code for my custom ViewModifier:

import SwiftUI

struct LimitCharactersModifier: ViewModifier {
    @Binding var text: String
    var limit: Int

    func body(content: Content) -> some View {
        content
            .onChange(of: text) { newValue in
                trimTextIfNeeded()
            }
            .onChange(of: limit) { newLimit in
                trimTextIfNeeded()
            }
    }

    private func trimTextIfNeeded() {
        if text.count > limit {
            text = String(text.prefix(limit))
        }
    }
}

extension View {
    func limitCharacters(text: Binding<String>, to limit: Int) -> some View {
        self.modifier(LimitCharactersModifier(text: text, limit: limit))
    }
}

Here’s how I am using it in my ContentView:

struct ContentView: View {
    @Binding var text: String
    var characterLimit: Int  // Limit is passed in from outside

    var body: some View {
        VStack {
            TextField("Enter text", text: $text)
                .limitCharacters(text: $text, to: characterLimit)
                .padding()
                .border(Color.gray)
        }
        .padding()
    }
}

Issue: although the modifier responding to characterLimit changes, the value does't seems to be updated and stays the same.

Question: Why the ViewModifier responding to the external changes but doesn't use the updated characterLimit values?

Any guidance or suggestions for a better approach would be appreciated!


Solution

  • Question: Why the ViewModifier responding to the external changes but doesn't use the updated characterLimit values?

    Answer: When you say that the ViewModifier responds to external changes, I assume it responds to the text provided from the outside. If so, be aware that it responds because the outside text is provided as a Binding.

    The reason it doesn't use the (outside) updated character limit is because the characterLimit is NOT provided as a Binding, but as a var - and a var of type Int or String (or even a struct) is a value type. Value types are passed by value, meaning they are copied when passed to functions or assigned to other variables.

    Note, though, that it is the var type (in this case Int) and not the use of a var that determines whether it's a value type or a reference type.

    This is because you can have a var that holds an object which is an instance of a class, which would make that var a reference type:

    var characterLimit: Int //value type
    
    var someOtherLimit: MyLimitClass //reference type 
    
    var specialLimit: CustomLimit = CustomLimit() //reference type
    

    A @Binding on the other hand is a reference to the state in the parent view, allowing the respective var to observe and update the value in both child view and parent/root view.

    If you need further insight into this, I suggest Apple's state and data flow tutorial.

    Here's the complete code with the necessary changes to support what you may be looking for and some minor restructuring explained below:

    struct LimitCharactersRootView: View {
        
        @State private var textFieldText = "Some longer filler text that will be trimmed..."
        @State private var characterLimit = 10
        
        var body: some View {
            Form {
                Section("Root View"){
                    //convenience textfield for controlling the length of text passed to child view textfield
                    TextField("Original text", text: $textFieldText)
                    
                    //convenience stepper for adjusting the character limit for testing
                    Stepper("Character limit: \(characterLimit)", value: $characterLimit, in: 5...50 )
                    
                }
                Section("Child View") {
                    LimitCharactersView(text: $textFieldText, characterLimit:  $characterLimit)
                        .padding()
                }
            }
        }
    }
    
    struct LimitCharactersView: View {
        
        @Binding var text: String //Text is passed in from outside
        @Binding var characterLimit: Int  // Limit is passed in from outside
    
        @State private var inputText: String = "" //local state for text field
        @State private var showLimitNotice = false //local state that show/hides the textfield character limit reached notice
    
        var body: some View {
            VStack(alignment: .leading) {
                Section {
                    TextField("Enter text", text: $inputText)
                        .limitCharacters(inputText: $inputText, to: characterLimit, originalText: $text, showNotice: $showLimitNotice)
                        .padding()
                        .border(Color.gray)
                } header: {
                    
                } footer: {
                    if showLimitNotice {
                        Text("* Character limit has been reached")
                            .font(.caption)
                            .foregroundStyle(.red)
                    }
                }
                
                //convenience button for testing purposes
                Button("Reset to original text"){
                    inputText = text
                }
                .padding(.top)
            }
        }
    }
    
    extension View {
        
        func limitCharacters(inputText: Binding<String>, to limit: Int, originalText: Binding<String>, showNotice: Binding<Bool>) -> some View {
            
            let input = inputText.wrappedValue //for convenience
            
            //computed property that holds the trimmed input text
            var trimmedText: String {
                input.count > limit ? String(input.prefix(limit)) : input
            }
            
            //computed property that determines if the text reached the character limit
            var notice: Bool {
                trimmedText.count == limit ? true : false
            }
            
            //helper computed property/closure to avoid typing the same lines in multiple places
            var update: () {
                inputText.wrappedValue = trimmedText
                showNotice.wrappedValue = notice
            }
            
            return self
            
                //when view initially loads, load the outside text into the textfield
                .onAppear {
                    inputText.wrappedValue = originalText.wrappedValue
                }
            
                //if outside limit changes, limit if needed based on character limit
                .onChange(of: limit) {
                    update
                }
            
                //if textfield text changes, limit if needed based on character limit
                .onChange(of: input) {
                    update
                }   
            
                //if original text changes, update the textfield (optional, based on use case)
                .onChange(of: originalText.wrappedValue) {
                    inputText.wrappedValue = originalText.wrappedValue
                }
        }
    }
    
    #Preview("Limit characters"){
        LimitCharactersRootView()
    }
    

    Some insight: