How to execute non-view code inside a SwiftUI view

6k Views Asked by At

I have been struggling with this over and over again, so I think I'm missing something. I need to do math, make a setting, assign a value or any of a host of simple operations in reaction to some user action, such as the example shown here, and SwiftUI is wanting a View where I don't need a view. There's got to be a way around the ViewBuilder's rules. I kind of worked around this by creating an unnecessary view and executing the code I need inside the View's init(), but that seems terribly awkward.

import SwiftUI

struct ContentView: View
{
    @State var showStuff = false

    var body: some View
    {
        VStack
        {
            Toggle(isOn: $showStuff)
            {
                Text("Label")
            }
            if showStuff
            {
                UserDefaults.standard.set(true, forKey: "Something")
            }
        }
    }
}
5

There are 5 best solutions below

0
On BEST ANSWER

I think the best way to do this now is using onChange(of:perform:) modifier, for iOS14.0+

These simple executions (like do math, assign a value) are nothing but actions in swift terminology, which should be performed after touching any UI element because swiftUI is declarative. In your case, you can use this with any View type. Other similar options are .onAppear() or .onDisappear() (self-explanatory).

Surprisingly, apple documentation for these are actually good and elaborate.

Link - https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)

0
On

In SwiftUI 2.0, there's a new ViewModifier onChange(of:perform:), that allows you to react to changes in values.

But you can create something similar to that with a neat trick (I forgot where I saw it, so unfortunately I can't leave proper attribution), by extending a Binding with onChange method:

extension Binding {
   func onChange(perform action: @escaping (Value, Value) -> Void) -> Self {
      .init(
         get: { self.wrappedValue },
         set: { newValue in
            let oldValue = self.wrappedValue
            DispatchQueue.main.async { action(newValue, oldValue) }
            self.wrappedValue = newValue
         })
   }
}

You can use it like so:

Toggle(isOn: $showStuff.onChange(perform: { (new, old) in
  if new {
     UserDefaults.standard.set(true, forKey: "Something")
  }
}))
0
On

You cannot do what you try to do, because actually every view block inside body is a ViewBuidler.buildBlock function arguments. Ie. you are in function arguments space. I hope you would not expect that expression like

foo(Toggle(), if showStuff { ... } )

would work (assuming foo is func foo(args: View...). But this is what you try to do in body.

So expressions in SwiftUI have to be out of ViewBuilder block (with some exceptions which ViewBuilder itself supports for views).

Here is a solution for your case:

SwiftUI 2.0

struct ContentView: View {
    @AppStorage("Something") var showStuff = false

    var body: some View {
        VStack {
            Toggle(isOn: $showStuff) {
                Text("Label")
            }
        }
    }
}

SwiftUI 1.0

Find in already solved SwiftUI toggle switches

Note: View.body (excluding some action modifiers) is equivalent of UIView.draw(_ rect:)... you don't store UserDefaults in draw(_ rect:), do you?

1
On

Way 1 (best):

struct ExecuteCode : View {
    init( _ codeToExec: () -> () ) {
        codeToExec()
    }
    
    var body: some View {
        EmptyView()
    }
}

usage:

HStack {
    ExecuteCode { 
        print("SomeView1 was re-drawn!")
        print("second print")
    }

    SomeView1()
}

Way 2:

( my first way is better - you're able to write only simple code here )

HStack {
    // `let _ =` works inside of View!
    let _ = print("SomeView1 was re-drawn!") 

    SomeView1()
}

Way 3:

( +- the same situation as in first way. Good enough solution. )

HStack {
    let _ = { // look here. "let _ =" is required
        print("SomeView1 was re-drawn!")
        print("second print")
    }() // look here. "()" is also required
    
    SomeView1()
}
0
On

Views are actually so-called Function Builders, and the contents of the view body are used as arguments to to the buildBlock function, as mentioned by @Asperi.

An alternative solution if you must run code inside this context is using a closure that returns the desired view:

VStack {
    // ... some views ...
    { () -> Text in
      // ... any code ...
      return Text("some view") }()
    // ... some views ...
}