I have a SwiftUI app with SwiftUI App life cycle. I'm trying to setup a standard way to add
typing debounce to TextFields. Ideally, I'd like to create my own TextField modifier that
can easily be applied to views that have many textfields to edit. I've tried a bunch of
ways to do this but I must be missing something fundamental. Here's one example. This
does not work:
struct ContentView: View {
#State private var searchText = ""
var body: some View {
VStack {
Text("You entered: \(searchText)")
.padding()
TextField("Enter Something", text: $searchText)
.frame(height: 30)
.padding(.leading, 5)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.blue, lineWidth: 1)
)
.padding(.horizontal, 20)
.onChange(of: searchText, perform: { _ in
var subscriptions = Set<AnyCancellable>()
let pub = PassthroughSubject<String, Never>()
pub
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.collect()
.sink(receiveValue: { t in
self.searchText = t.first ?? "nothing"
} )
.store(in: &subscriptions)
})
}
}
}
Any guidance would be appreciated. Xcode 12.4, iOS 14.4
I think you'll have to keep two variables: one for the text in the field as the user is typing and one for the debounced text. Otherwise, the user wouldn't see the typing coming in in real-time, which I'm assuming isn't the behavior you want. I'm guessing this is probably for the more standard use case of, say, performing a data fetch once the user has paused their typing.
I like ObservableObjects and Combine to manage this sort of thing:
class TextFieldObserver : ObservableObject {
#Published var debouncedText = ""
#Published var searchText = ""
private var subscriptions = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] t in
self?.debouncedText = t
} )
.store(in: &subscriptions)
}
}
struct ContentView: View {
#StateObject var textObserver = TextFieldObserver()
#State var customText = ""
var body: some View {
VStack {
Text("You entered: \(textObserver.debouncedText)")
.padding()
TextField("Enter Something", text: $textObserver.searchText)
.frame(height: 30)
.padding(.leading, 5)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.blue, lineWidth: 1)
)
.padding(.horizontal, 20)
Divider()
Text(customText)
TextFieldWithDebounce(debouncedText: $customText)
}
}
}
struct TextFieldWithDebounce : View {
#Binding var debouncedText : String
#StateObject private var textObserver = TextFieldObserver()
var body: some View {
VStack {
TextField("Enter Something", text: $textObserver.searchText)
.frame(height: 30)
.padding(.leading, 5)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.blue, lineWidth: 1)
)
.padding(.horizontal, 20)
}.onReceive(textObserver.$debouncedText) { (val) in
debouncedText = val
}
}
}
I included two examples -- the top, where the container view (ContentView) owns the ObservableObject and the bottom, where it's made into a more-reusable component.
A little simplified version of text debouncer from #jnpdx
Note that .assign(to: &$debouncedText) doesn't create a reference cycle and manages subscription for you automatically
class TextFieldObserver : ObservableObject {
#Published var debouncedText = ""
#Published var searchText = ""
init(delay: DispatchQueue.SchedulerTimeType.Stride) {
$searchText
.debounce(for: delay, scheduler: DispatchQueue.main)
.assign(to: &$debouncedText)
}
}
If you are not able to use an ObservableObject (ie, if your view is driven by a state machine, or you are passing the input results to a delegate, or are simply publishing the input), there is a way to accomplish the debounce using only view code. This is done by forwarding text changes to a local Publisher, then debouncing the output of that Publisher.
struct SomeView: View {
#State var searchText: String = ""
let searchTextPublisher = PassthroughSubject<String, Never>()
var body: some View {
TextField("Search", text: $searchText)
.onChange(of: searchText) { searchText in
searchTextPublisher.send(searchText)
}
.onReceive(
searchTextPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
) { debouncedSearchText in
print(debouncedSearchText)
}
}
}
Or if broadcasting the changes:
struct DebouncedSearchField: View {
#Binding var debouncedSearchText: String
#State private var searchText: String = ""
private let searchTextPublisher = PassthroughSubject<String, Never>()
var body: some View {
TextField("Search", text: $searchText)
.onChange(of: searchText) { searchText in
searchTextPublisher.send(searchText)
}
.onReceive(
searchTextPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
) { debouncedSearchText in
self.debouncedSearchText = debouncedSearchText
}
}
}
However, if you have the choice, it may be more "correct" to go with the ObservableObject approach.
Tunous on GitHub added a debounce extension to onChange recently. https://github.com/Tunous/DebouncedOnChange that is super simple to use. Instead of adding .onChange(of: value) {newValue in doThis(with: newValue) } you can add .onChange(of: value, debounceTime: 0.8 /sec/ ) {newValue in doThis(with: newValue) }
He sets up a Task that sleeps for the debounceTime but it is cancelled and reset on every change to value. The view modifier he created uses a State var debounceTask. It occurred to me that this task could be a Binding instead and shared amount multiple onChange view modifiers allowing many textfields to be modified on the same debounce. This way if you programmatically change a bunch of text fields using the same debounceTask only one call to the action is made, which is often what one wants to do. Here is the code with a simple example.
//
// Debounce.swift
//
// Created by Joseph Levy on 7/11/22.
// Based on https://github.com/Tunous/DebouncedOnChange
import SwiftUI
import Combine
extension View {
/// Adds a modifier for this view that fires an action only when a time interval in seconds represented by
/// `debounceTime` elapses between value changes.
///
/// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next
/// action /// will be scheduled to run after that time passes again. This mean that the action will only execute
/// after changes to the value /// stay unmodified for the specified `debounceTime` in seconds.
///
/// - Parameters:
/// - value: The value to check against when determining whether to run the closure.
/// - debounceTime: The time in seconds to wait after each value change before running `action` closure.
/// - action: A closure to run when the value changes.
/// - Returns: A view that fires an action after debounced time when the specified value changes.
public func onChange<Value>(
of value: Value,
debounceTime: TimeInterval,
perform action: #escaping (_ newValue: Value) -> Void
) -> some View where Value: Equatable {
self.modifier(DebouncedChangeViewModifier(trigger: value, debounceTime: debounceTime, action: action))
}
/// Same as above but adds before action
/// - debounceTask: The common task for multiple Values, but can be set to a different action for each change
/// - action: A closure to run when the value changes.
/// - Returns: A view that fires an action after debounced time when the specified value changes.
public func onChange<Value>(
of value: Value,
debounceTime: TimeInterval,
task: Binding< Task<Void,Never>? >,
perform action: #escaping (_ newValue: Value) -> Void
) -> some View where Value: Equatable {
self.modifier(DebouncedTaskBindingChangeViewModifier(trigger: value, debounceTime: debounceTime, debouncedTask: task, action: action))
}
}
private struct DebouncedChangeViewModifier<Value>: ViewModifier where Value: Equatable {
let trigger: Value
let debounceTime: TimeInterval
let action: (Value) -> Void
#State private var debouncedTask: Task<Void,Never>?
func body(content: Content) -> some View {
content.onChange(of: trigger) { value in
debouncedTask?.cancel()
debouncedTask = Task.delayed(seconds: debounceTime) { #MainActor in
action(value)
}
}
}
}
private struct DebouncedTaskBindingChangeViewModifier<Value>: ViewModifier where Value: Equatable {
let trigger: Value
let debounceTime: TimeInterval
#Binding var debouncedTask: Task<Void,Never>?
let action: (Value) -> Void
func body(content: Content) -> some View {
content.onChange(of: trigger) { value in
debouncedTask?.cancel()
debouncedTask = Task.delayed(seconds: debounceTime) { #MainActor in
action(value)
}
}
}
}
extension Task {
/// Asynchronously runs the given `operation` in its own task after the specified number of `seconds`.
///
/// The operation will be executed after specified number of `seconds` passes. You can cancel the task earlier
/// for the operation to be skipped.
///
/// - Parameters:
/// - time: Delay time in seconds.
/// - operation: The operation to execute.
/// - Returns: Handle to the task which can be cancelled.
#discardableResult
public static func delayed(
seconds: TimeInterval,
operation: #escaping #Sendable () async -> Void
) -> Self where Success == Void, Failure == Never {
Self {
do {
try await Task<Never, Never>.sleep(nanoseconds: UInt64(seconds * 1e9))
await operation()
} catch {}
}
}
}
// MultiTextFields is an example
// when field1, 2 or 3 change the number times is incremented by one, one second later
// when field changes the three other fields are changed too but the increment task only
// runs once because they share the same debounceTask
struct MultiTextFields: View {
#State var debounceTask: Task<Void,Never>?
#State var field: String = ""
#State var field1: String = ""
#State var field2: String = ""
#State var field3: String = ""
#State var times: Int = 0
var body: some View {
VStack {
HStack {
TextField("Field", text: $field).padding()
.onChange(of: field, debounceTime: 1) { newField in
field1 = newField
field2 = newField
field3 = newField
}
Text(field+" \(times)").padding()
}
Divider()
HStack {
TextField("Field1", text: $field1).padding()
.onChange(of: field1, debounceTime: 1, task: $debounceTask) {_ in
times+=1 }
Text(field1+" \(times)").padding()
}
HStack {
TextField("Field2", text: $field2).padding()
.onChange(of: field2, debounceTime: 1, task: $debounceTask) {_ in
times+=1 }
Text(field2+" \(times)").padding()
}
HStack {
TextField("Field3", text: $field3).padding()
.onChange(of: field3, debounceTime: 1, task: $debounceTask) {_ in
times+=1 }
Text(field3+" \(times)").padding()
}
}
}
}
struct View_Previews: PreviewProvider {
static var previews: some View {
MultiTextFields()
}
}
I haven't tried the shared debounceTask binding using an ObservedObject or StateObject, just a State var as yet. If anyone tries that please post the result.
Related
I am making QuizApp. Currently I have viewModel in which questions are fetched and stored in #Published array.
class HomeViewModel: ObservableObject {
let repository: QuestionRepository
#Published var questions: [Question] = []
#Published var isLoading: Bool = true
private var cancellables: Set<AnyCancellable> = .init()
init(repository: QuestionRepository){
self.repository = repository
getQuestions()
}
private func getQuestions(){
repository
.getQuestions()
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] questions in
self?.isLoading = false
self?.questions = questions
}
)
.store(in: &cancellables)
}
func updateQuestions(){
questions.removeFirst()
if questions.count < 2 {
getQuestions()
}
}
}
In QuestionContainerView HomeViewModel is created as #StateObject and from it, first data from questions array is used and passed to QuestionView.
#StateObject private var viewModel: HomeViewModel = HomeViewModel(repository: QuestionRepositoryImpl())
var body: some View {
if viewModel.isLoading {
ProgressView()
} else {
VStack(alignment: .leading, spacing: 16) {
if let question = viewModel.questions.first {
QuestionView(question: question){
viewModel.updateQuestions()
}
} else {
Text("No more questions")
.font(.title2)
}
}
.padding()
}
}
QuestionView has two properties, Question and showNextQuestion callback.
let question: Question
let showNextQuestion: () -> Void
And when some button is pressed in that view, callBack is called after 2.5s and after that viewModel function updateQuestions is called.
struct QuestionView: View {
let question: Question
let showNextQuestion: () -> Void
#State private var showCorrectAnswer: Bool = false
#State private var timeRemaining = 10
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("\(timeRemaining)")
.font(.title2)
Text(question.question)
.font(.title2)
.padding()
ForEach(question.allAnswers, id: \.self){ answer in
Button(
action: {
showCorrectAnswer.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
showNextQuestion()
}
},
label: {
Text(answer)
.font(.title2)
.padding()
.background(getBackgroundColor(answer: answer))
.clipShape(Capsule())
}
)
}
Spacer()
}
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
} else {
showNextQuestion()
}
}
}
My idea was to pass first item from viewModel array to QuestionView and after some Button action in QuestionView I wanted to remove firstItem from array and pass next firstItem.
But problem is that QuestionView is not updated (it is not rerendered) and it contains some data from past item - I added timer in QuestionView which is counting down and when question is changed, timer value is still same as for before question, it is not reseted.
I thought that marking viewModel array property with #Published will trigger whole QuestionContainerView render with new viewModel first item from array, but it is not updated as I wanted.
There are several mistakes in the SwiftUI code, one or all could contribute to the problem, here are the ones I noticed:
We don't use view model objects in SwiftUI for view data, that's the job of the View struct and property wrappers.
When ObservableObject is being used for model data, it's usually a singleton (one for the app and another for previews) and passed in as environmentObject. We don't usually use the reference version of #State, i.e. #StateObject for holding the model since we don't want model lifetime tied to any view on screen, it has to be tied to the app executable's lifetime. Also, #StateObject are disabled for previews since usually those are used for network downloads.
In an ObservableObject we .assign(to: &$propertyName) the end of the pipeline to an #Published var, we don't use sink or need cancellables in this case. This ties the pipeline's lifetime to the object's, if you use sink you need to cancel it yourself when the object de-inits (Not required for singletons but it's good to learn the pattern).
Since your timer is a let it will be lost every time the QuestionView is re-init, to fix it needs to be #State.
ForEach is a View not a for loop. You have either supply Identifiable data or an id param, you can't use id:\.self for dynamic data or it'll crash when it changes.
I have 3 views. Content View, TrainingView and TrainingList View. I want to list exercises from Core Data but also I want to make some changes without changing data.
In ContentView; I am trying to fetch data with CoreData
struct ContentView: View {
// MARK: - PROPERTY
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Training.timestamp, ascending: false)],
animation: .default)
private var trainings: FetchedResults<Training>
#State private var showingAddProgram: Bool = false
// FETCHING DATA
// MARK: - FUNCTION
// MARK: - BODY
var body: some View {
NavigationView {
Group {
VStack {
HStack {
Text("Your Programs")
Spacer()
Button(action: {
self.showingAddProgram.toggle()
}) {
Image(systemName: "plus")
}
.sheet(isPresented: $showingAddProgram) {
AddProgramView()
}
} //: HSTACK
.padding()
List {
ForEach(trainings) { training in
TrainingListView(training: training)
}
} //: LIST
Spacer()
} //: VSTACK
} //: GROUP
.navigationTitle("Good Morning")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
print("test")
}) {
Image(systemName: "key")
}
}
} //: TOOLBAR
.onAppear() {
}
} //: NAVIGATION
}
private func showId(training: Training) {
guard let id = training.id else { return }
print(id)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
In TrainingView; I am getting exercises as a array list and I am pushing into to TrainingListView.
import SwiftUI
struct TrainingView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State var training: Training
#State var exercises: [Exercise]
#State var tempExercises: [Exercise] = [Exercise]()
#State var timeRemaining = 0
#State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var isTimerOn = false
var body: some View {
VStack {
HStack {
Text("\(training.name ?? "")")
Spacer()
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Finish")
}
}
.padding()
ZStack {
Circle()
.fill(Color.blue)
.frame(width: 250, height: 250)
Circle()
.fill(Color.white)
.frame(width: 240, height: 240)
Text("\(timeRemaining)s")
.font(.system(size: 100))
.fontWeight(.ultraLight)
.onReceive(timer) { _ in
if isTimerOn {
if timeRemaining > 0 {
timeRemaining -= 1
} else {
isTimerOn.toggle()
stopTimer()
removeExercise()
}
}
}
}
Button(action: {
startResting()
}) {
if isTimerOn {
Text("CANCEL")
} else {
Text("GIVE A BREAK")
}
}
Spacer()
ExerciseListView(exercises: $tempExercises)
}
.navigationBarHidden(true)
.onAppear() {
updateBigTimer()
}
}
private func startResting() {
tempExercises = exercises
if let currentExercise: Exercise = tempExercises.first {
timeRemaining = Int(currentExercise.rest)
startTimer()
isTimerOn.toggle()
}
}
private func removeExercise() {
if let currentExercise: Exercise = tempExercises.first {
if Int(currentExercise.rep) == 1 {
let index = tempExercises.firstIndex(of: currentExercise) ?? 0
tempExercises.remove(at: index)
} else if Int(currentExercise.rep) > 1 {
currentExercise.rep -= 1
let index = tempExercises.firstIndex(of: currentExercise) ?? 0
tempExercises.remove(at: index)
tempExercises.insert(currentExercise, at: index)
}
updateBigTimer()
}
}
private func updateBigTimer() {
timeRemaining = Int(tempExercises.first?.rest ?? 0)
}
private func stopTimer() {
timer.upstream.connect().cancel()
}
private func startTimer() {
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
}
struct TrainingView_Previews: PreviewProvider {
static var previews: some View {
TrainingView(training: Training(), exercises: [Exercise]())
}
}
In TrainingListView; I am listing all exercises.
struct TrainingListView: View {
#ObservedObject var training: Training
#Environment(\.managedObjectContext) private var managedObjectContext
var body: some View {
NavigationLink(destination: TrainingView(training: training, exercises: training.exercises?.toArray() ?? [Exercise]())) {
HStack {
Text("\(training.name ?? "")")
Text("\(training.exercises?.count ?? 0) exercises")
}
}
}
}
Also, I am adding video: https://twitter.com/huseyiniyibas/status/1388571724346793986
What I want to do is, when user taps any Training Exercises List should refreshed. It should be x5 again like in the beginning.
I had a hard time understanding your question but I guess I got the idea.
My understanding is this:
You want to store the rep count in the Core Data. (Under Training > Exercises)
You want to count down the reps one by one as the user completes the exercise.
But you don't want to change the original rep count stored in the Core Data.
I didn't run your code since I didn't want to recreate all the models and Core Data files. I guess I've spotted the problem. Here I'll explain how you can solve it:
The Core Data models are classes (reference types). When you pass around the classes (as you do in your code) and change their properties, you change the original data. In your case, you don't want that.
(Btw, being a reference type is a very useful and powerful property of classes. Structs and enums are value types, i.e. they are copied when passed around. The original data is unchanged.)
You have several options to solve your problem:
Just generate a different struct (something like ExerciseDisplay) from Exercise, and pass ExerciseDisplay to TrainingView.
You can write an extension to Exercise and "copy" the model before passing it to TrainingView. For this you'll need to implement the NSCopying protocol.
extension Exercise: NSCopying {
func copy(with zone: NSZone? = nil) -> Any {
return Exercise(...)
}
}
But before doing this I guess you'll need to change the Codegen to Manual/None of your entry in your .xcdatamodeld file. This is needed when you want to create the attributes manually. I'm not exactly sure how you can implement NSCopying for a CoreDate model, but it's certainly doable.
The first approach is easier but kinda ugly. The second is more versatile and elegant, but it's also more advanced. Just try the first approach first and move to the second once you feel confident.
Update:
This is briefly how you can implement the 1st approach:
struct ExerciseDisplay: Identifiable, Equatable {
public let id = UUID()
public let name: String
public var rep: Int
public let rest: Int
}
struct TrainingView: View {
// Other properties and states etc.
let training: Training
#State var exercises: [ExerciseDisplay] = []
init(training: Training) {
self.training = training
}
var body: some View {
VStack {
// Views
}
.onAppear() {
let stored: [Exercise] = training.exercises?.toArray() ?? []
self.exercises = stored.map { ExerciseDisplay(name: $0.name ?? "", rep: Int($0.rep), rest: Int($0.rest)) }
}
}
}
I have a SwiftUI app with SwiftUI App lifecycle that includes a master-detail type
list driven from CoreData. I have the standard list in ContentView and NavigationLinks
to the DetailView. I pass a Core Data entity object to the Detailview.
My struggle is setting-up bindings to TextFields in the DetailView for data entry
and for editing. I tried to create an initializer which I could not make work. I have
only been able to make it work with the following. Assigning the initial values
inside the body does not seem like the best way to do this, though it does work.
Since the Core Data entities are ObservableObjects I thought I should be able to
directly access and update bound variables, but I could not find any way to reference
a binding to Core Data in a ForEach loop.
Is there a way to do this that is more appropriate than my code below?
Simplified Example:
struct DetailView: View {
var thing: Thing
var count: Int
#State var localName: String = ""
#State private var localComment: String = ""
#State private var localDate: Date = Date()
//this does not work - cannot assign String? to State<String>
// init(t: Thing) {
// self._localName = t.name
// self._localComment = t.comment
// self._localDate = Date()
// }
var body: some View {
//this is the question - is this safe?
DispatchQueue.main.async {
self.localName = self.thing.name ?? "no name"
self.localComment = self.thing.comment ?? "No Comment"
self.localDate = self.thing.date ?? Date()
}
return VStack {
Text("\(thing.count)")
.font(.title)
Text(thing.name ?? "no what?")
TextField("name", text: $localName)
Text(thing.comment ?? "no comment?")
TextField("comment", text: $localComment)
Text("\(thing.date ?? Date())")
//TextField("date", text: $localDate)
}.padding()
}
}
And for completeness, the ContentView:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Thing.date, ascending: false)])
private var things : FetchedResults<Thing>
#State private var count: Int = 0
#State private var coverDeletedDetail = false
var body: some View {
NavigationView {
List {
ForEach(things) { thing in
NavigationLink(destination: DetailView(thing: thing, count: self.count + 1)) {
HStack {
Image(systemName: "gear")
.resizable()
.frame(width: 40, height: 40)
.onTapGesture(count: 1, perform: {
updateThing(thing)
})
Text(thing.name ?? "untitled")
Text("\(thing.count)")
}
}
}
.onDelete(perform: deleteThings)
if UIDevice.current.userInterfaceIdiom == .pad {
NavigationLink(destination: WelcomeView(), isActive: self.$coverDeletedDetail) {
Text("")
}
}
}
.navigationTitle("Thing List")
.navigationBarItems(trailing: Button("Add Task") {
addThing()
})
}
}
private func updateThing(_ thing: FetchedResults<Thing>.Element) {
withAnimation {
thing.name = "Updated Name"
thing.comment = "Updated Comment"
saveContext()
}
}
private func deleteThings(offsets: IndexSet) {
withAnimation {
offsets.map { things[$0] }.forEach(viewContext.delete)
saveContext()
self.coverDeletedDetail = true
}
}
private func addThing() {
withAnimation {
let newThing = Thing(context: viewContext)
newThing.name = "New Thing"
newThing.comment = "New Comment"
newThing.date = Date()
newThing.count = Int64(self.count + 1)
self.count = self.count + 1
saveContext()
}
}
func saveContext() {
do {
try viewContext.save()
} catch {
print(error)
}
}
}
And Core Data:
extension Thing {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Thing> {
return NSFetchRequest<Thing>(entityName: "Thing")
}
#NSManaged public var comment: String?
#NSManaged public var count: Int64
#NSManaged public var date: Date?
#NSManaged public var name: String?
}
extension Thing : Identifiable {
}
Any guidance would be appreciated. Xcode 12.2 iOS 14.2
You already mentioned it. CoreData works great with SwiftUI.
Just make your Thing as ObservableObject
#ObservedObject var thing: Thing
and then you can pass values from thing as Binding. This works in ForEach aswell
TextField("name", text: $thing.localName)
For others - note that I had to use the Binding extension above since NSManagedObjects are optionals. Thus as davidev stated:
TextField("name", text: Binding($thing.name, "no name"))
And ObservedObject, not Observable
Scenario: I want to handle the selection event whilst harvesting the selected item via a Picker.
This is in reference to an introduction discussion to the Picker Proxy.
This is what I have so far. I can't get the event handler to fire/activate/run doSomething().
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { _ in
VStack {
Text("PickerView")
.font(.headline)
.foregroundColor(.gray)
.padding(.top, 10)
Picker("test", selection: Binding(get: { "" }, set: { _ in
doSomething()
})) {
Text("Hello").id("1")
Text("Uncle").id("2")
Text("Ric").id("3")
}.labelsHidden()
}.background(RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.white).shadow(radius: 1))
}
.padding()
}
func doSomething() {
print("Hello Something!")
}
}
Note:
I don't know what to do with the get{} so I put a null string there to satisfy the compiler.
I tried evaluating the closure parameter (via print(*closure parameter*)) but got no value, so I placed a _ placeholder to satisfy the compiler.
How to I harvest the selection?
The closure parameter doesn't appear to work here; hence the placeholder. There aren't many examples to follow.
Here is a possible solution:
struct ContentView: View {
// extract picker values for easier access
private let items = ["Hello", "Uncle", "Ric"]
// store the currently selected value
#State private var selection = 0
// custom binding for the `selection`
var binding: Binding<Int> {
.init(get: {
selection
}, set: {
selection = $0
doSomething() // call another function after the `selection` is set
})
}
var body: some View {
Picker("test", selection: binding) {
ForEach(0 ..< items.count) { index in // use `ForEach` to quickly generate picker values
Text(items[index])
.tag(index) // use `tag` instead of `id`
}
}
.labelsHidden()
}
func doSomething() {
print("Hello Something!")
}
}
Discussion:
You can use onChange to trigger a side effect as the result of a value changing, such as an Environment key or a Binding...
(Apple doc.)
struct ContentView: View {
#State
private var selection = 0
let items = ["Hello", "Uncle", "Ric"]
var body: some View {
VStack {
Picker("", selection: $selection) {
ForEach(0..<items.count) {
Text(items[$0])
.tag($0)
}
}
.onChange(of: selection) { _ in
print("Hello Something!")
}
}
}
}
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() }
})
}
}
}
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)
}
}
Copy and paste solution based on #Damiaan Dufaux's answer.
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.
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
}
}
}
}
}
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)
})
}
}
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() }
})
}
}
}
#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.
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.