ohsnapy
ohsnapy

Reputation: 553

SwiftUI: Get notified when @Binding value changes

I wrote a view to create a typewriter effect in SwiftUI - when I pass in a binding variable it works fine the first time, e.g.: TypewriterTextView($textString)

However, any subsequent time the textString value changes it will not work (since the binding value isn't directly being placed in the body). Am interested in any ideas on how to manually be notified when the @Binding var is changed within the view.

struct TypewriterTextView: View {

    @Binding var textString:String
    @State private var typingInterval = 0.3
    @State private var typedString = ""

    var body: some View {
        Text(typedString).onAppear() {
            Timer.scheduledTimer(withTimeInterval: self.typingInterval, repeats: true, block: { timer in

                if self.typedString.length < self.textString.length {
                    self.typedString = self.typedString + self.textString[self.typedString.length]
                }
                else { timer.invalidate() }
            })
        }
    }
}

Upvotes: 43

Views: 48069

Answers (6)

yue Max
yue Max

Reputation: 239

Copy and paste solution based on @Damiaan Dufaux's answer.

  1. Use it just like the system .onChange API. It prefers to use the system-provided .onChange on iOS 14 and uses the backup plan on lower.
  2. action will not be called when changed to the same value. (If you use @Damiaan Dufaux's answer, you may find the action being called even if data changes to same value, because model is recreated every time.)
struct ChangeObserver<Content: View, Value: Equatable>: View {
    let content: Content
    let value: Value
    let action: (Value) -> Void

    init(value: Value, action: @escaping (Value) -> Void, content: @escaping () -> Content) {
        self.value = value
        self.action = action
        self.content = content()
        _oldValue = State(initialValue: value)
    }

    @State private var oldValue: Value

    var body: some View {
        if oldValue != value {
            DispatchQueue.main.async {
                oldValue = value
                self.action(self.value)
            }
        }
        return content
    }
}

extension View {
    func onDataChange<Value: Equatable>(of value: Value, perform action: @escaping (_ newValue: Value) -> Void) -> some View {
        Group {
            if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
                self.onChange(of: value, perform: action)
            } else {
                ChangeObserver(value: value, action: action) {
                    self
                }
            }
        }
    }
}

Upvotes: 9

Flatout
Flatout

Reputation: 350

You can use onReceive with Just wrapper to use it in iOS 13.

struct TypewriterTextView: View {
    @Binding var textString:String
    @State private var typingInterval = 0.3
    @State private var typedString = ""

    var body: some View {
        Text(typedString)
          .onReceive(Just(textString)) {
            typedString = ""
            Timer.scheduledTimer(withTimeInterval: self.typingInterval, repeats: true, block: { timer in

                if self.typedString.length < self.textString.length {
                    self.typedString = self.typedString + self.textString[self.typedString.length]
                }
                else { timer.invalidate() }
            })
        }
    }
}

Upvotes: 8

Alhomaidhi
Alhomaidhi

Reputation: 666

You can use textString.wrappedValue like this:

 struct TypewriterTextView: View {

      @Binding var textString: String
      @State private var typingInterval = 0.3
      @State private var typedString = ""

      var body: some View {
          Text(typedString).onAppear() {
              Timer.scheduledTimer(withTimeInterval: self.typingInterval, repeats: true, block: { timer in

                  if self.typedString.length < self.textString.length {
                      self.typedString = self.typedString + self.textString[self.typedString.length]
                  }
                  else { timer.invalidate() }
              })
          }
          .onChange(of: $textString.wrappedValue, perform: { value in
                  print(value)
          })
      }
  }

Upvotes: 6

malhal
malhal

Reputation: 30549

@Binding should only be used when your child View needs to write the value. In your case, you only need to read it so change it to let textString:String and body will run every time it changes. Which is when this View is recreated with the new value in the parent View. This is how SwiftUI works, it only runs body if the View struct vars (or lets) have changed since the last time the View was created.

Upvotes: 2

Damiaan Dufaux
Damiaan Dufaux

Reputation: 4785

Use the onChange modifier instead of onAppear() to watch the textString binding.

struct TypewriterTextView: View {
    @Binding var textString:String
    @State private var typingInterval = 0.3
    @State private var typedString = ""

    var body: some View {
        Text(typedString).onChange(of: textString) {
            typedString = ""
            Timer.scheduledTimer(withTimeInterval: self.typingInterval, repeats: true, block: { timer in

                if self.typedString.length < self.textString.length {
                    self.typedString = self.typedString + self.textString[self.typedString.length]
                }
                else { timer.invalidate() }
            })
        }
    }
}

Compatibility

The onChange modifier was introduced at WWDC 2020 and is only available on

  • macOS 11+
  • iOS 14+
  • tvOS 14+
  • watchOS 7+

If you want to use this functionality on older systems you can use the following shim. It is basically the onChange method reimplemented using an older SwiftUI:

import Combine
import SwiftUI

/// See `View.onChange(of: value, perform: action)` for more information
struct ChangeObserver<Base: View, Value: Equatable>: View {
    let base: Base
    let value: Value
    let action: (Value)->Void

    let model = Model()

    var body: some View {
        if model.update(value: value) {
            DispatchQueue.main.async { self.action(self.value) }
        }
        return base
    }

    class Model {
        private var savedValue: Value?
        func update(value: Value) -> Bool {
            guard value != savedValue else { return false }
            savedValue = value
            return true
        }
    }
}

extension View {
    /// Adds a modifier for this view that fires an action when a specific value changes.
    ///
    /// You can use `onChange` to trigger a side effect as the result of a value changing, such as an Environment key or a Binding.
    ///
    /// `onChange` is called on the main thread. Avoid performing long-running tasks on the main thread. If you need to perform a long-running task in response to value changing, you should dispatch to a background queue.
    ///
    /// The new value is passed into the closure. The previous value may be captured by the closure to compare it to the new value. For example, in the following code example, PlayerView passes both the old and new values to the model.
    ///
    /// ```
    /// struct PlayerView : View {
    ///   var episode: Episode
    ///   @State private var playState: PlayState
    ///
    ///   var body: some View {
    ///     VStack {
    ///       Text(episode.title)
    ///       Text(episode.showTitle)
    ///       PlayButton(playState: $playState)
    ///     }
    ///   }
    ///   .onChange(of: playState) { [playState] newState in
    ///     model.playStateDidChange(from: playState, to: newState)
    ///   }
    /// }
    /// ```
    ///
    /// - Parameters:
    ///   - value: The value to check against when determining whether to run the closure.
    ///   - action: A closure to run when the value changes.
    ///   - newValue: The new value that failed the comparison check.
    /// - Returns: A modified version of this view
    func onChange<Value: Equatable>(of value: Value, perform action: @escaping (_ newValue: Value)->Void) -> ChangeObserver<Self, Value> {
        ChangeObserver(base: self, value: value, action: action)
    }
}

Upvotes: 39

meaning-matters
meaning-matters

Reputation: 22936

You can use a so called publisher for this:

public let subject = PassthroughSubject<String, Never>()

Then, inside your timer block you call:

self.subject.send()

Typically you want the above code to be outside your SwiftUI UI declaration.

Now in your SwiftUI code you need to receive this:

Text(typedString)
    .onReceive(<...>.subject)
    { (string) in
        self.typedString = string
    }

<...> need to be replaced by where your subject object is. For example (as a hack on AppDelegate):

 .onReceive((UIApplication.shared.delegate as! AppDelegate).subject)

I know the above should work when typedString is a @State:

@State private var typedString = ""

but I guess it should also work with a @Binding; just haven't tried that yet.

Upvotes: 1

Related Questions