SwiftUI call function on variable change
Asked Answered
R

3

23

I am trying to convert a view of my watchOS App to Swift UI. I wanted to port the volume control that can be found in watchKit to SwiftUI with custom controls. In the image below you can see the current state of the view.

The volume control changes the progress of the ring according to current state volume and the volume also changes when I turn the Digital Crown. Without SwiftUI it was possible to call a function on the turn of the crown. This has changed and the system only allows me to bind a variable to it.

What I want to do know is to bind a variable and call a function on every change of that variable. Because the normal Digital Crown behavior does not fulfill my needs.

One thing that works, but is far from perfect is:

.digitalCrownRotation($crownAccumulator, from: -100.0, through: 100.0, by: 1, sensitivity: .low, isContinuous: true, isHapticFeedbackEnabled: true)
.onReceive(Just(self.crownAccumulator), perform: self.crownChanged(crownAccumulator:))

OnReceive will be called with every twist of the crown, but it will also be called with every other update to the view.

So what I want is a pipeline like this:

Crown rotates → crownAccumulator changes → Function called async → Function updates volume

In the past I would have done this with a didSet, but this is no longer available

Here the code of it:


    @ObservedObject var group: Group
    @State var animateSongTitle: Bool = false

    @State var songTitle: String = "Very long song title that should be scrolled"
    @State var artist: String = "Artist name"


    @State var volume: Int = 30
    @State var isMuted = false
    @State var crownAccumulator: CGFloat = 0.0


    var body: some View {

       VStack(alignment: .center) {
            TitleView(songTitle: $songTitle, artist: $artist)
            GroupControlButtons(
                skipPreviousAction: skipPrevious,
                skipNextAction: skipNext,
                playPauseAction: playPause,
                group: group)

            ZStack {
                HStack {
                    VolumeControl(
                        volumeLevel: $volume,
                        isMuted: $isMuted,
                        muteAction: self.muteButtonPressed)
                            .frame(minWidth: 40.0, idealWidth: 55, minHeight: 40.0, idealHeight: 55, alignment: .center)
                            .aspectRatio(1.0, contentMode: .fit)


                }
            }


        }
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
        .edgesIgnoringSafeArea([.bottom])
        .focusable(true)
//        .digitalCrownRotation($crownAccumulator)
        .digitalCrownRotation($crownAccumulator, from: -100.0, through: 100.0, by: 1, sensitivity: .low, isContinuous: true, isHapticFeedbackEnabled: true)
        .onReceive(Just(self.crownAccumulator), perform: self.crownChanged(crownAccumulator:))


Here's the current view:
Current View

Reciprocate answered 17/12, 2019 at 17:18 Comment(1)
How you solved "@State var songTitle: String = "Very long song title that should be scrolled" I have same problem my text don't fitFecundity
D
18

You can use a custom Binding, that calls some code in set. For example from :

extension Binding {
    /// Execute block when value is changed.
    ///
    /// Example:
    ///
    ///     Slider(value: $amount.didSet { print($0) }, in: 0...10)
    func didSet(execute: @escaping (Value) ->Void) -> Binding {
        return Binding(
            get: {
                return self.wrappedValue
            },
            set: {
                self.wrappedValue = $0
                execute($0)
            }
        )
    }
}
Devaney answered 18/12, 2019 at 11:53 Comment(1)
I don't really understand this 'hack'. The principle of SwiftUI is to automatically react to binding changes, so why add some extra code? You set a value on a '@Published' property, and all the views are redrawn. If you use combine only, all observers are notified and then you do what's needed at the right place. $property.sink { // do this or that }.Murmurous
G
6

As you said "In the past I would have done this with a didSet, but this is no longer available" I test below code it works perfectly

struct pHome: View {
 @State var prog:Int = 2 {
    willSet{
        print("willSet: newValue =\(newValue)  oldValue =\(prog)") 
    }
    didSet{
        print("didSet: oldValue=\(oldValue) newValue=\(prog)")
        //Write code, function do what ever you want todo 
    }
} 
 var body: some View {
VStack{
                Button("Update"){
                    self.prog += 1
                }
                Text("\(self.prog)%")
  }
 }
}
Giveaway answered 18/12, 2019 at 5:31 Comment(3)
True! It works for state variables. On the volume control I have a binding with $volume and didSet does not work on bindings as far as I see it.Reciprocate
use current object reference to access directly @State var instead ** bindings**. pass self as parameterGiveaway
didSet does not execute for me. The State variable is passed to a separate View where it is bound. didSet in the parent View is apparently not called in this case.Ludwigg
M
3

Just a guess, but you could use .onChange instead of .onReceive, it would then only be called when value changes.

However, neither .onChange or .onReceive are needed here

This is slightly different subject, but there is too much @State variables in this view. ( I guess there were, since it is a relatively old question )

Don't forget @State declares a new source of truth for the view, therefore it should be used with parcimony ( like for a display mode, a filter, or any property concerning only the view ). In this case, as in many cases, the source of truth is outside..

So by simply binding the view to the model, the cycle User action -> value update -> view update is done naturally.

Then, in the didSet of your player crownAccumulator property, you do the change on the model you need to do, and the view will update automatically accordingly to the new change. 

Note that you may need to show other views in your app, but still be able to change volume with the crown. So I suggest to also move the crown accumulator into the player state.

View Side

// The current tune information, provided by model ( Song name, artist, duration..) - Could also be in the PlayerState, since there is only one player and one tune playing in the whole application
@ObservedObject var currentTune: TuneInfo

// The global player state ( Volume, isMuted, isPaused.. )
@EnvironmentObject var playerState: PlayerState

var body: View {
   VStack {
      ...
   }
   .digitalCrownRotation($playerState.crownAccumulator, from: -100.0, through: 100.0, by: 1, sensitivity: .low, isContinuous: true, isHapticFeedbackEnabled: true)
}

Model/Controller side:

struct PlayerState {
    // Published values that will retrigger view rendering when changed
    @Published var isMuted = false
    @Published var volume: Int = 30

    var crownAccumulator: CGFloat {
       didSet { volume = volumeForCrownValue() }
    }

    private func volumeForCrownValue() -> Int {
         // Your formula to compute volume
    }
}
Murmurous answered 29/12, 2020 at 21:16 Comment(1)
Yes, you are right. This solution is the one I chose in the most recent version as it is the most elegant. onChange is relatively new (watchOS 7+), but I saw some code that ports it back to older versionsReciprocate

© 2022 - 2024 — McMap. All rights reserved.