SwiftUI:当@Binding值更改时收到通知

32

我在SwiftUI中编写了一个视图,用于创建打字机效果 - 当我传递一个binding变量时,第一次它可以正常工作,例如:TypewriterTextView($textString)

然而,任何后续更改textString值的操作都不起作用(因为binding值并没有直接放置在body中)。 我想知道有没有任何想法可以手动通知视图内部@Binding var值已经发生更改。

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() }
            })
        }
    }
}
6个回答

32

使用onChange修饰符来监视textString绑定,而不是使用onAppear()

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() }
            })
        }
    }
}

兼容性

onChange修饰符是在2020年WWDC上引入的,仅适用于:

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

如果您想在旧系统上使用此功能,可以使用以下替代方法。它基本上是使用旧版SwiftUI重新实现的onChange方法:

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)
    }
}

我尝试了一下,它直接就能工作。然后我读了一下代码,意识到这个答案是如此聪明!只需使用一个视图,因为它知道何时需要更新,然后执行分配的操作。 - Sn0wfreeze
这是一个非常好的介绍。我刚刚将它与ScrollViewReader结合使用,以便在选定项更改时滚动到“选定”项,并且它非常顺利地工作了。 - Eric Groom
@Binding 在这里是一个错误,使用 onChange 修复这个错误会让事情变得更糟。 textString 应该是 let,因为这个 View 并不对它进行写入操作。 - malhal
这一开始似乎是有效的,但正如@yue-Max指出的那样,它每次都会触发.onChange(即使值相同),因为model每次都被重新创建。 - mylogon

8

这是基于@Damiaan Dufaux的答案的复制粘贴解决方案。

  1. 可以像系统提供的.onChange API一样使用它。 它倾向于在iOS 14上使用系统提供的.onChange,并在较低版本上使用备用方案。
  2. 当更改为相同的值时,不会调用action。(如果您使用@Damiaan Dufaux的答案,则可能会发现即使数据更改为相同的值,也会调用action,因为每次都会重新创建model。)
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
                }
            }
        }
    }
}

1
当更改为相同的值时,不会调用操作。谢谢,这就是其他答案所缺少的内容。 - aheze

4
您可以这样使用 textString.wrappedValue
 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)
          })
      }
  }

onChange 大于 iOS 14 并且如上所述。 - brainray
最干净的解决方案,而不需要将类更改为ObservableObject。 - Tuan Anh Vu

4
您可以使用Just包装器中的onReceive来在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() }
            })
        }
    }
}

1

@Binding只用在你的子视图需要写入值时使用。在你的情况下,你只需要读取它,所以将其更改为let textString:String,每次更改时body将运行。也就是说,当此View在父View中具有新值重新创建时。这就是SwiftUI的工作方式,仅在自上次创建View以来,View结构变量(或常数)已更改时才运行body。


0
你可以使用所谓的发布者来实现这个功能:
public let subject = PassthroughSubject<String, Never>()

然后,在你的计时器块中调用:

self.subject.send()

通常你希望上述代码在 SwiftUI UI 声明之外。
现在在你的 SwiftUI 代码中,你需要接收它:
Text(typedString)
    .onReceive(<...>.subject)
    { (string) in
        self.typedString = string
    }

<...> 需要被替换为你的 subject 对象。例如(作为对 AppDelegate 的 hack):

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

typedString是一个@State时,我知道上述代码应该可以工作:

@State private var typedString = ""

但我猜它也应该适用于 @Binding;只是我还没有尝试过。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接