0

Is it possible to add a view modifier inside .onChange?

Simplified example:

content
  .onChange(of: publishedValue) {
    content.foregroundColor(.red)
  }

I have a theme that when changed needs to change the status bar color. I have a view modifier created for that ( https://barstool.engineering/set-the-ios-status-bar-style-in-swiftui-using-a-custom-view-modifier/ ). The modifier works fine, but I need to update it as the publishedValue changes.

Actual minimal example:

import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: TestViewModel

    var body: some View {
        ZStack {
            Rectangle().foregroundColor(.mint)
            VStack(alignment: .center, spacing: 25) {
                Text("Test text \(viewModel.publishedValue)")
                    .onChange(of: viewModel.publishedValue) { newValue in
                        // Change status bar color
                        if viewModel.publishedValue % 2 == 0 {
                            self.body.statusBarStyle(.lightContent)
                        } else {
                            self.body.statusBarStyle(.darkContent)
                        }
                    }
                Button("Increment") {
                    viewModel.publishedValue += 1
                }
            }
        }
        .ignoresSafeArea()
        .statusBarStyle(.lightContent)
    }
}

class TestViewModel: ObservableObject {
    @Published var publishedValue: Int

    init(publishedValue: Int) {
        self.publishedValue = publishedValue
    }
}

extension View {
    /// Overrides the default status bar style with the given `UIStatusBarStyle`.
    ///
    /// - Parameters:
    ///   - style: The `UIStatusBarStyle` to be used.
    func statusBarStyle(_ style: UIStatusBarStyle) -> some View {
        return self.background(HostingWindowFinder(callback: { window in
            guard let rootViewController = window?.rootViewController else { return }
            let hostingController = HostingViewController(rootViewController: rootViewController, style: style)
            window?.rootViewController = hostingController
        }))
    }

}

fileprivate class HostingViewController: UIViewController {
    private var rootViewController: UIViewController?
    private var style: UIStatusBarStyle = .default

    init(rootViewController: UIViewController, style: UIStatusBarStyle) {
        self.rootViewController = rootViewController
        self.style = style
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let child = rootViewController else { return }
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }

    override var preferredStatusBarStyle: UIStatusBarStyle {
        return style
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        setNeedsStatusBarAppearanceUpdate()
    }
}

fileprivate struct HostingWindowFinder: UIViewRepresentable {
    var callback: (UIWindow?) -> ()

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        // ...
    }
}

GitHub repo for the example project: https://github.com/Iikeli/view-modifier-test

Iiro Alhonen
  • 352
  • 3
  • 19

3 Answers3

3

You are way overcomplicating this. Since your viewModel is @ObservedObject and the publishedValue is Published, the body of your View will be recalculated automatically every time publishedValue is updated. There's no need for a manual onChange.

You can simply move the logic into the input argument of statusBarStyle.

var body: some View {
    ZStack {
        Rectangle().foregroundColor(.mint)
        VStack(alignment: .center, spacing: 25) {
            Text("Test text \(viewModel.publishedValue)")
            Button("Increment") {
                viewModel.publishedValue += 1
            }
        }
    }
    .ignoresSafeArea()
    .statusBarStyle(viewModel.publishedValue % 2 == 0 ? .lightContent : .darkContent)
}

Or even better, move the logic into a separate computed property:

var body: some View {
    ....
    .statusBarStyle(statusBarStyle)
}

private var statusBarStyle: UIStatusBarStyle {
    viewModel.publishedValue % 2 == 0 ? .lightContent : .darkContent
}
Dávid Pásztor
  • 51,403
  • 9
  • 85
  • 116
1

The short answer is no, but that doesn't mean you can't use it to have views change based on some .onChange(..) action. For example.

@State var somethingChanged = false 
Text(somethingChanged ? "First Value" : "Second Value")

// Your code/view
    .onChange(..) {
         //Some Condition or whatever you want.
         somethingChanged = true
    }

Your usage might look something like this.

content
    .foregroundColor(somethingChanged ? .red : .blue)
    .onChange(ofPublishedValue) {
        somethingChanged = true
    }
xTwisteDx
  • 2,152
  • 1
  • 9
  • 25
1

First of all, thanks for the help. Neither of the answers helped in my situation, since I couldn't get the modifier to update with the variable change. But with some Googling and trying out different solutions I figured out a working solution for updating the status bar colors.

I needed to update the style variable in the HostingViewController, and then update accordingly. So I added the HostingViewController as a @StateObject and updated the style variable inside the .onChange(). Not quite the solution I was going with to start out, but it does work.

The code:

import SwiftUI
import Introspect

struct ContentView: View {
    @ObservedObject var viewModel: TestViewModel
    @StateObject var hostingViewController: HostingViewController = .init(rootViewController: nil, style: .default)

    var body: some View {
        ZStack {
            Rectangle().foregroundColor(.mint)
            VStack(alignment: .center, spacing: 25) {
                Text("Test text \(viewModel.publishedValue)")
                    .onChange(of: viewModel.publishedValue) { newValue in
                        // Change status bar color
                        if viewModel.publishedValue % 2 == 0 {
                            hostingViewController.style = .lightContent
                        } else {
                            hostingViewController.style = .darkContent
                        }

                    }
                Button("Increment") {
                    viewModel.publishedValue += 1
                }
            }
        }
        .ignoresSafeArea()
        .introspectViewController { viewController in
            let window = viewController.view.window
            guard let rootViewController = window?.rootViewController else { return }
            hostingViewController.rootViewController = rootViewController
            window?.rootViewController = hostingViewController
        }
    }
}

class TestViewModel: ObservableObject {
    @Published var publishedValue: Int

    init(publishedValue: Int) {
        self.publishedValue = publishedValue
    }
}

class HostingViewController: UIViewController, ObservableObject {
    var rootViewController: UIViewController?

    var style: UIStatusBarStyle = .default {
        didSet {
            self.rootViewController?.setNeedsStatusBarAppearanceUpdate()
        }
    }

    init(rootViewController: UIViewController?, style: UIStatusBarStyle) {
        self.rootViewController = rootViewController
        self.style = style
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let child = rootViewController else { return }
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }

    override var preferredStatusBarStyle: UIStatusBarStyle {
        return style
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        setNeedsStatusBarAppearanceUpdate()
    }
}

Big shoutout to this answer for giving me all that I needed to implement the solution. Note: You can override the rootViewController however you like, I used SwiftUI-Introspect as we are already using it in our project anyway.

GitHub branch

Iiro Alhonen
  • 352
  • 3
  • 19