SwiftUI - Changing parent #State doesn't update child View - swift

Specific question:
SwiftUI doesn't like us initializing #State using parameters from the parent, but what if the parent holding that #State causes major performance issues?
Example:
How do I make tapping on the top text change the slider to full/empty?
Dragging the slider correctly communicates upwards when the slider changes from full to empty, but tapping the [Overview] full: text doesn't communicate downwards that the slider should change to full/empty.
I could store the underlying Double in the parent view, but it causes major lag and seems unnecessary.
import SwiftUI
// Top level View. It doesn't know anything about specific slider percentages,
// it only knows if the slider got moved to full/empty
struct SliderOverviewView: View {
// Try setting this to true and rerunning.. It DOES work here?!
#State var overview = OverviewModel(state: .empty)
var body: some View {
VStack {
Text("[Overview] full: \(overview.state.rawValue)")
.onTapGesture { // BROKEN: should update child..
switch overview.state {
case .full, .between: overview.state = .empty
case .empty: overview.state = .full
}
}
SliderDetailView(overview: $overview)
}
}
}
// Bottom level View. It knows about specific slider percentages and only
// communicates upwards when percentage goes to 0% or 100%.
struct SliderDetailView: View {
#State var details: DetailModel
init(overview: Binding<OverviewModel>) {
details = DetailModel(overview: overview)
}
var body: some View {
VStack {
Text("[Detail] percentFull: \(details.percentFull)")
Slider(value: $details.percentFull)
.padding(.horizontal, 48)
}
}
}
// Top level model that only knows if slider went to 0% or 100%
struct OverviewModel {
var state: OverviewState
enum OverviewState: String {
case empty
case between
case full
}
}
// Lower level model that knows full slider percentage
struct DetailModel {
#Binding var overview: OverviewModel
var percentFull: Double {
didSet {
if percentFull == 0 {
overview.state = .empty
} else if percentFull == 1 {
overview.state = .full
} else {
overview.state = .between
}
}
}
init(overview: Binding<OverviewModel>) {
_overview = overview
// set inital percent
switch overview.state.wrappedValue {
case .empty:
percentFull = 0.0
case .between:
percentFull = 0.5
case .full:
percentFull = 1.0
}
}
}
struct SliderOverviewView_Previews: PreviewProvider {
static var previews: some View {
SliderOverviewView()
}
}
Why don't I just store percentFull in the OverviewModel?
I'm looking for a pattern so my top level #State struct doesn't need to know EVERY low level detail specific to certain Views.
Running the code example is the clearest way to see my problem.
This question uses a contrived example where an Overview only knows if the slider is full or empty, but the Detail knows what percentFull the slider actually is. The Detail has very detailed control and knowledge of the slider, and only communicates upwards to the Overview when the slider is 0% or 100%
What's my specific case for why I need to do this?
For those curious, my app is running into performance issues because I have several gestures that give the user control over progress. I want my top level ViewModel to store if the gesture is complete or not, but it doesn't need to know the specifics of how far the user has swiped. I'm trying to hide this specific progress Double from my higher level ViewModel to improve app performance.

Here is working, simplified and refactored answer for your issue:
struct ContentView: View {
var body: some View {
SliderOverviewView()
}
}
struct SliderOverviewView: View {
#State private var overview: OverviewModel = OverviewModel(full: false)
var body: some View {
VStack {
Text("[Overview] full: \(overview.full.description)")
.onTapGesture {
overview.full.toggle()
}
SliderDetailView(overview: $overview)
}
}
}
struct SliderDetailView: View {
#Binding var overview: OverviewModel
var body: some View {
VStack {
Text("[Detail] percentFull: \(tellValue(value: overview.full))")
Slider(value: Binding(get: { () -> Double in
return tellValue(value: overview.full)
}, set: { newValue in
if newValue == 1 { overview.full = true }
else if newValue == 0 { overview.full = false }
}))
}
}
func tellValue(value: Bool) -> Double {
if value { return 1 }
else { return 0 }
}
}
struct OverviewModel {
var full: Bool
}
Update:
struct SliderDetailView: View {
#Binding var overview: OverviewModel
#State private var sliderValue: Double = Double()
var body: some View {
VStack {
Text("[Detail] percentFull: \(sliderValue)")
Slider(value: $sliderValue, in: 0.0...1.0)
}
.onAppear(perform: { sliderValue = tellValue(value: overview.full) })
.onChange(of: overview.full, perform: { newValue in
sliderValue = tellValue(value: newValue)
})
.onChange(of: sliderValue, perform: { newValue in
if newValue == 1 { overview.full = true }
else { overview.full = false }
})
}
func tellValue(value: Bool) -> Double {
value ? 1 : 0
}
}

I present here a clean alternative using 2 ObservableObject, a hight level OverviewModel that
only deal with if slider went to 0% or 100%, and a DetailModel that deals only with the slider percentage.
Dragging the slider correctly communicates upwards when the slider changes from full to empty, and
tapping the [Overview] full: text communicates downwards that the slider should change to full/empty.
import Foundation
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var overview = OverviewModel()
var body: some View {
SliderOverviewView().environmentObject(overview)
}
}
// Top level View. It doesn't know anything about specific slider percentages,
// it only cares if the slider got moved to full/empty
struct SliderOverviewView: View {
#EnvironmentObject var overview: OverviewModel
var body: some View {
VStack {
Text("[Overview] full: \(overview.state.rawValue)")
.onTapGesture {
switch overview.state {
case .full, .between: overview.state = .empty
case .empty: overview.state = .full
}
}
SliderDetailView()
}
}
}
// Bottom level View. It knows about specific slider percentages and only
// communicates upwards when percentage goes to 0% or 100%.
struct SliderDetailView: View {
#EnvironmentObject var overview: OverviewModel
#StateObject var details = DetailModel()
var body: some View {
VStack {
Text("[Detail] percentFull: \(details.percentFull)")
Slider(value: $details.percentFull).padding(.horizontal, 48)
.onChange(of: details.percentFull) { newVal in
switch newVal {
case 0: overview.state = .empty
case 1: overview.state = .full
default: break
}
}
}
// listen for the high level OverviewModel changes
.onReceive(overview.$state) { theState in
details.percentFull = theState == .full ? 1.0 : 0.0
}
}
}
enum OverviewState: String {
case empty
case between
case full
}
// Top level model that only knows if slider went to 0% or 100%
class OverviewModel: ObservableObject {
#Published var state: OverviewState = .empty
}
// Lower level model that knows full slider percentage
class DetailModel: ObservableObject {
#Published var percentFull = 0.0
}

Related

Blinking symbol with didSet in SwiftUI

This is synthesized from a much larger app. I'm trying to blink an SF symbol in SwiftUI by activating a timer in a property's didSet. A print statement inside timer prints the expected value but the view doesn't update.
I'm using structs throughout my model data and am guessing this will have something to do with value vs. reference types. I'm trying to avoid converting from structs to classes.
import SwiftUI
import Combine
#main
struct TestBlinkApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class Model: ObservableObject {
#Published var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Item1"), Item("Item2"), Item("Item3"), Item("Item4")]
return model
}
}
struct Item {
static let ledBlinkTimer: TimeInterval = 0.5
private let ledTimer = Timer.publish(every: ledBlinkTimer, tolerance: ledBlinkTimer * 0.1, on: .main, in: .default).autoconnect()
private var timerSubscription: AnyCancellable? = nil
var name: String
var isLEDon = false
var isLedBlinking = false {
didSet {
var result = self
print("in didSet: isLedBlinking: \(result.isLedBlinking) isLEDon: \(result.isLEDon)")
guard result.isLedBlinking else {
result.isLEDon = true
result.ledTimer.upstream.connect().cancel()
print("Cancelling timer.")
return
}
result.timerSubscription = result.ledTimer
.sink { _ in
result.isLEDon.toggle()
print("\(result.name) in ledTimer isLEDon: \(result.isLEDon)")
}
}
}
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.foregroundColor(model.items[0].isLEDon ? .green : color)
Button("Toggle") {
model.items[0].isLedBlinking.toggle()
}
}
.foregroundColor(color)
}
}
Touching the "Toggle" button starts the timer that's suppose to blink the circle. The print statement shows the value changing but the view doesn't update. Why??
You can use animation to make it blink, instead of a timer.
The model of Item gets simplified, you just need a boolean variable, like this:
struct Item {
var name: String
// Just a toggle: blink/ no blink
var isLedBlinking = false
init(_ name: String) {
self.name = name
}
}
The "hard work" is done by the view: changing the variable triggers or stops the blinking. The animation does the magic:
struct ContentView: View {
#StateObject var model = Model.loadData
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
.padding()
// Change based on isLedBlinking
Image(systemName: model.items[0].isLedBlinking ? "circle.fill" : "circle")
.font(.largeTitle)
.foregroundColor(model.items[0].isLedBlinking ? .green : color)
// Animates the view based on isLedBlinking: when is blinking, blinks forever, otherwise does nothing
.animation(model.items[0].isLedBlinking ? .easeInOut.repeatForever() : .default, value: model.items[0].isLedBlinking)
.padding()
Button("Toggle: \(model.items[0].isLedBlinking ? "Blinking" : "Still")") {
model.items[0].isLedBlinking.toggle()
}
.padding()
}
.foregroundColor(color)
}
}
A different approach with a timer:
struct ContentView: View {
#StateObject var model = Model.loadData
let timer = Timer.publish(every: 0.25, tolerance: 0.1, on: .main, in: .common).autoconnect()
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
if model.items[0].isLedBlinking {
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.onReceive(timer) { _ in
model.items[0].isLEDon.toggle()
}
.foregroundColor(model.items[0].isLEDon ? .green : color)
} else {
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.foregroundColor(model.items[0].isLEDon ? .green : color)
}
Button("Toggle: \(model.items[0].isLedBlinking ? "Blinking" : "Still")") {
model.items[0].isLedBlinking.toggle()
}
}
.foregroundColor(color)
}
}

SwiftUI: How to update child view with #Binding variable before updating state of parent view?

I have a basic setup to show another view over my ContentView using the code below.
struct ContentView: View {
#State var otherViewShowing: Bool = true
var body: some View {
if otherViewShowing {
OtherView(amIShowing: $otherViewShowing)
} else {
Text("Hello, world!")
}
}
}
When I set the "otherViewShowing" variable to false through changing "amIShowing" in the button in OtherView, though, the view abruptly disappears and the text for "Hello, world!" is shown immediately. I'm trying to get the OtherView to play the scaling down animation before updating the ContentView to show "Hello, world!" so it's a bit smoother.
struct OtherView: View {
#Binding var amIShowing: Bool
#State private var scaleAmount: CGFloat = 1
var body: some View {
VStack {
Button("Tap me") {
withAnimation(.default) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)
}
}
Any thoughts on accomplishing this? Thank you in advance.
you can give duration and delay for this.
delay: Delays the animation for 2 seconds.
duration: Determines the duration of the animation.
VStack {
Button("Tap me") {
withAnimation(Animation.easeIn(duration: 2).delay(2)) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)

How to correctly handle Picker in Update Views (SwiftUI)

I'm quite new to SwiftUI and I'm wondering how I should use a picker in an update view correctly.
At the moment I have a form and load the data in with .onAppear(). That works fine but when I try to pick something and go back to the update view the .onAppear() gets called again and I loose the picked value.
In the code it looks like this:
import SwiftUI
struct MaterialUpdateView: View {
// Bindings
#State var material: Material
// Form Values
#State var selectedUnit = ""
var body: some View {
VStack(){
List() {
Section(header: Text("MATERIAL")){
// Picker for the Unit
Picker(selection: $selectedUnit, label: Text("Einheit")) {
ForEach(API().units) { unit in
Text("\(unit.name)").tag(unit.name)
}
}
}
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Does anyone has experience with that problem or am I doing something terribly wrong?
You need to create a custom binding which we will implement in another subview. This subview will be initialised with the binding vars selectedUnit and material
First, make your MaterialUpdateView:
struct MaterialUpdateView: View {
// Bindings
#State var material : Material
// Form Values
#State var selectedUnit = ""
var body: some View {
NavigationView {
VStack(){
List() {
Section(header: Text("MATERIAL")) {
MaterialPickerView(selectedUnit: $selectedUnit, material: $material)
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Then, below, add your MaterialPickerView, as shown:
Disclaimer: You need to be able to access your API() from here, so move it or add it in this view. As I have seen that you are re-instanciating it everytime, maybe it is better that you store its instance with let api = API() and then refer to it with api, and even pass it to this view as such!
struct MaterialPickerView: View {
#Binding var selectedUnit: String
#Binding var material : Material
#State var idx: Int = 0
var body: some View {
let binding = Binding<Int>(
get: { self.idx },
set: {
self.idx = $0
self.selectedUnit = API().units[self.idx].name
self.material.unit = self.selectedUnit
})
return Picker(selection: binding, label: Text("Einheit")) {
ForEach(API().units.indices) { i in
Text(API().units[i].name).tag(API().units[i].name)
}
}
}
}
That should do,let me know if it works!

Is there a way to fade in/out a view (e.g. an Image) when changing the content of said view in SwiftUI?

I have an Image, predictionManager.prediction.sentiment.currentSentiment.0, where predictionManager is an #EnvironmentObject and predictionManager.prediction is an #Published variable. predictionManager.prediction.sentiment.currentSentiment.0 gets changed to a new Image, at which point I would like the original Image to fade out and the new one to fade in.
I attempted to do this by using the .onReceive(publisher:) {} modifier like so:
struct ContentView: View {
[...]
#EnvironmentObject private var predictionManager: PredictionManager
#State private var imageOpacity: Double = 1
var body: some View {
NavigationView {
VStack {
Spacer()
predictionManager.prediction.sentiment.currentSentiment.0
.font(.system(size: 100))
.opacity(imageOpacity)
.padding()
.onReceive(predictionManager.objectWillChange) {
imageOpacity = 0
}
[...]
}
}
}
}
However, this code will always set the original Image's opacity to 0 — it never gets set back to 1. Since there isn't (to my knowledge) an afterUpdate() method which is called after the State is updated and the Image is switched out (or something to that effect), so how would achieve my goal?
If it helps, I imagine in UIKit I imagine that I'd do something like the following (I'm not going to test the code, so at the very least it will serve as pseudocode to aid the description of the problem):
var imageView = UIImageView() {
willSet {
UIView.animate(withDuration: 1) {
self.imageView.alpha = 0
}
}
didSet {
UIView.animate(withDuration: 1) {
self.imageView.alpha = 1
}
}
}
Explicit animation can be used in this scenario. Hope the below example might help you in that direction
struct ContentView: View {
#State private var opacity = 0.2
var body: some View {
Button(action: {
withAnimation (.linear(duration: 2)) {
self.opacity += 0.2
}
}) {
Text("Animate Opacity")
.padding()
.opacity(opacity)
}
}
}

SwiftUI instanced #State variable

I am quite new to SwiftUI. I have a following "Counter" view that counts up every second. I want to "reset" the counter when the colour is changed:
struct MyCounter : View {
let color: Color
#State private var count = 0
init(color:Color) {
self.color = color
_count = State(initialValue: 0)
}
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
Here is my main view that uses counter:
struct ContentView: View {
#State var black = true
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow)
Button(action:{self.black.toggle()}) { Text("Toggle") }
}
}
}
When i click "Toggle" button, i see MyCounter constructor being called, but #State counter persists and never resets. So my question is how do I reset this #State value? Please note that I do not wish to use counter as #Binding and manage that in the parent view, but rather MyCounter be a self-contained widget. (this is a simplified example. the real widget I am creating is a sprite animator that performs sprite animations, and when I swap the image, i want the animator to start from frame 0). Thanks!
There are two way you can solve this issue. One is to use a binding, like E.Coms explained, which is the easiest way to solve your problem.
Alternatively, you could try using an ObservableObject as a view model for your timer. This is the more flexible solution. The timer can be passed around and it could also be injected as an environment object if you so desire.
class TimerModel: ObservableObject {
// The #Published property wrapper ensures that objectWillChange signals are automatically emitted.
#Published var count: Int = 0
init() {}
func start() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
func reset() {
count = 0
}
}
Your timer view then becomes
struct MyCounter : View {
let color: Color
#ObservedObject var timer: TimerModel
init(color: Color, timer: TimerModel) {
self.color = color
self.timer = timer
}
var body: some View {
Text("\(timer.count)").foregroundColor(color)
.onAppear(){
self.timer.start()
}
}
}
Your content view becomes
struct ContentView: View {
#State var black = true
#ObservedObject var timer = TimerModel()
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow, timer: self.timer)
Button(action: {
self.black.toggle()
self.timer.reset()
}) {
Text("Toggle")
}
}
}
}
The advantage of using an observable object is that you can then keep track of your timer better. You could add a stop() method to your model, which invalidates the timer and you can call it in a onDisappear block of your view.
One thing that you have to be careful about this approach is that when you're using the timer in a standalone fashion, where you create it in a view builder closure with MyCounter(color: ..., timer: TimerModel()), every time the view is rerendered, the timer model is replaced, so you have to make sure to keep the model around somehow.
You need a binding var:
struct MyCounter : View {
let color: Color
#Binding var count: Int
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
struct ContentView: View {
#State var black = true
#State var count : Int = 0
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow , count: $count)
Button(action:{self.black.toggle()
self.count = 0
}) { Text("Toggle") }
}
}
}
Also you can just add one State Value innerColor to help you if you don't like binding.
struct MyCounter : View {
let color: Color
#State private var count: Int = 0
#State private var innerColor: Color?
init(color: Color) {
self.color = color
}
var body: some View {
return Text("\(self.count)")
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}.foregroundColor(color).onReceive(Just(color), perform: { color in
if self.innerColor != self.color {
self.count = 0
self.innerColor = color}
})
}
}