Inconsistent behavior of SwiftUI ObservableObject depending on source of call site - swiftui

In an ongoing quest to pass data from a SpriteKit scene to a SwiftUI view, I have discovered the following mystery (to me, at least). I hope the solution might break the impasse.
I have a ContentView which uses SpriteView() to contain/display a SpriteKit scene called GameScene.
I have a class called Counter(), which is subclassed as an ObservableObject. (Note the print statement in the body of the add(count) func.)
import SwiftUI
class Counter: ObservableObject {
#Published var count : Int = 0
func add(count: Int) {
self.count += count
print("Add \(count); new total: \(self.count)")
}
}
In ContentView, for the purpose of testing and comparison, I have added a button which calls the add(count) func:
import SwiftUI
import SpriteKit
struct ContentView: View {
#ObservedObject var counter = Counter()
var scene: SKScene {
let scene = GameScene()
scene.size = CGSize(width: 300, height: 400)
scene.scaleMode = .fill
return scene
}
var body: some View {
VStack{
SpriteView(scene: scene)
.frame(width: 300, height: 400)
.ignoresSafeArea()
Button{
counter.add(count: 1)
} label: {
Text("Add to count")
}
Text("New count = \(counter.count)")
}
}
}
When the button (in ContentView) is tapped, the count increments and is displayed immediately as expected.
In GameScene I have virtually the same call to the add(count) func, but it fails (refuses?) to update the ContentView.
class GameScene: SKScene {
var counter = Counter()
var count = 0
...
//a SpriteKitNode called "button" is created then added in didMove(toView)//
...
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if button.contains(location) {
counter.add(count: 1)
}
}
}
The print statement reads the same whether the call comes from GameScene or ContentView. With the first tap of either button it reads:
Add 1; new total: 1
Add 1; new total: 2
Add 1; new total: 3 , and so on.
In other words, up until the call to the func that is meant to update the published var, they seem to behave identically. But...
The Mystery:
Why does the call from ContentView trigger the desired update while the same call from GameScene does not?
I look forward to having the scales removed from my weary eyes!

In your GameScene, you're creating a brand new instance of Counter when you declare the property:
var counter = Counter()
Instead, you should be passing the instance of Counter owned by ContentView to GameScene so that they are mutating the same object.
You could create an initializer for GameScene to take a Counter as a parameter, or you could do something like this:
//in GameScene:
var counter : Counter?
//in GameScene when the button is pressed:
counter?.add(count: 1)
//in ContentView:
let scene = GameScene()
scene.counter = counter

Related

NSStatusBarButton with a NSHostingView autosize

I have a NSStatusBarButton that contains a NSHostingView containing a Swift UI View.
I'd like that my Button and his subview to be the size of the Swift UI view content size.
The code looks like that:
// CustomSwiftUIView
import SwiftUI
struct CustomSwiftUIView: View {
var body: some View {
Text("Hello world, I have a dynamic width :)")
}
}
// AppDelegate
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
func applicationDidFinishLaunching(_ aNotification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
let mySwiftUIView = CustomSwiftUIView()
let innerView = NSHostingView(rootView: mySwiftUIView);
button.addSubview(innerView)
}
}
}
If I don't explicitly set a width and an height to the Button AND to the innerView, the button size is 0,0 and nothing is displayed.
Any idea how I can achieve the desired behavior?
Thanks!

How to create an SwiftUI animation effect from the Model?

I have a model object, which has a published property displayMode, which is updated asynchronously via events from the server.
class RoomState: NSObject, ObservableObject {
public enum DisplayMode: Int {
case modeA = 0
case modeB = 1
case modeC = 2
}
#Published var displayMode = DisplayMode.modeA
func processEventFromServer(newValue: DisplayMode) {
DispatchQueue.main.async {
self.displayMode = newValue
}
}
}
Then, I have a View, which displays this mode by placing some image in a certain location depending on the value.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
}
}
}
This code works fine, but I want to animate the movement when the value changes. If I change the value in the code block inside the View, I can use withAnimation {..} to create an animation effect, but I am not able to figure out how to do it from the model.
This is the answer, thanks to #aheze. With .animation(), this Image view always animates when the state.displayMode changes.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
.animation(.easeInOut)
}
}
}

SwiftUI not releasing model instances

Having a SwiftUI project generated by Xcode, and adding a custom MyView with a MyViewModel.
The ContentView just renders MyView.
The problem:
When the ContentView gets reloaded (the reload button changes its state), MyViewModel gets somehow disconnected from MyView (the MyView counter stops incrementing in the UI when the button is clicked), but the console logs show the incrementation works.
If the model subscribes to a publisher, it does not get unsubscribed because the instance is not released. Therefore, the instances still process incoming messages and alter the app's data and state.
Looking at the instance counters and memory addresses in the console:
Every time the ContentView gets refreshed, a new MyView and MyViewModel instances get created. However, the counter incrementation uses the original first-created model instance.
Some model instances did not get released.
EDIT: The model needs to be recreated every time MyView is recreated.
import SwiftUI
struct ContentView: View {
#State
private var reloadCounter = 0
var body: some View {
VStack {
Button(action: { self.reloadCounter += 1 },
label: { Text("Reload view") })
Text("Reload counter: \(reloadCounter)")
MyView().environmentObject(MyViewModel())
}
}
}
import SwiftUI
struct MyView: View {
#EnvironmentObject
private var model: MyViewModel
var body: some View {
VStack {
Button(action: { self.model.counter += 1 },
label: { Text("Increment counter") })
Text("Counter value: \(model.counter)")
}
.frame(width: 480, height: 300)
}
init() { withUnsafePointer(to: self) { print("Initialising MyView struct instance \(String(format: "%p", $0))") }}
}
import Combine
class MyViewModel: ObservableObject {
private static var instanceCount: Int = 0 { didSet {
print("SettingsViewModel: \(instanceCount) instances")
}}
#Published
var counter: Int = 0 { didSet {
print("Model counter: \(counter), self: \(Unmanaged.passUnretained(self).toOpaque())")
}}
init() { print("Initialising MyViewModel class instance \(Unmanaged.passUnretained(self).toOpaque())"); Self.instanceCount += 1 }
deinit { print("Deinitialising MyViewModel class instance \(Unmanaged.passUnretained(self).toOpaque())"); Self.instanceCount -= 1 }
}
Any clue what did I do wrong?
The image below depicts the app's UI after all the actions in the logs were performed.
You create new view model on every refresh, so just move it outside body, like
struct ContentView: View {
#State
private var reloadCounter = 0
private let vm = MyViewModel() // << here !!
var body: some View {
VStack {
Button(action: { self.reloadCounter += 1 },
label: { Text("Reload view") })
Text("Reload counter: \(reloadCounter)")
MyView().environmentObject(vm) // << reference only !!
}
}
}
Note: if you don't need it deeply in sub-view hierarchy then consider to use #ObservedObject instead of #EnvironmentObject and pass reference via constructor (because environment object is stored somewhere outside and you have less control over its life-cycle)

How to create a SwiftUI button that changes opacity and triggers button action when touched?

I wanna create a button with SwiftUI that fires the moment my finger touches it (like UIKit's touch down instead of touch up inside). I also want the opacity of the button to become 0.7 when my finger is pressing the button. And I want the opacity of the button to change back to 1 ONLY when my finger is no longer touching the button.
I've tried 2 different types of button styles to create such a button but both of them failed:
struct ContentView: View {
var body: some View {
Button(action: {
print("action triggered")
}){
Text("Button").padding()
}
.buttonStyle(SomeButtonStyle())
}
}
struct SomeButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.background(Color.green)
.opacity(configuration.isPressed ? 0.7 : 1)
.onLongPressGesture(
minimumDuration: 0,
perform: configuration.trigger//Value of type 'SomeButtonStyle.Configuration' (aka 'ButtonStyleConfiguration') has no member 'trigger'
)
}
}
struct SomePrimativeButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(Color.green)
.opacity(configuration.isPressed ? 0.7 : 1)//Value of type 'SomePrimativeButtonStyle.Configuration' (aka 'PrimitiveButtonStyleConfiguration') has no member 'isPressed'
.onLongPressGesture(
minimumDuration: 0,
perform: configuration.trigger
)
}
}
Apparently none of the button styles above worked because ButtonStyle and PrimitiveButtonStyle don't share the same methods and properties so I can't use both the isPressed property (which belongs to ButtonStyle) AND the trigger method (which belongs to PrimitiveButtonStyle) in the same button style.
How should I configure my button style to make this work?
Ok, I understand that author wants to see solution only with Button, so I dig a little more. And found something interesting at Swift UI Lab. The idea is the same as in my first answer: use #GestureState and create LongPressGesture which .updating($...) this state. But in PrimitiveButtonStyle you don't need to compose a few gestures together. So, I simplified code a little and tested it at simulator. And I think now it just what author need:
struct ComposingGestures: View {
var body: some View {
Button(action: {
print("action triggered")
}){
Text("Button")
.padding()
}
.buttonStyle(MyPrimitiveButtonStyle())
}
}
struct MyPrimitiveButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
MyButton(configuration: configuration)
}
struct MyButton: View {
#GestureState private var pressed = false
let configuration: PrimitiveButtonStyle.Configuration
let color: Color = .green
#State private var didTriggered = false
var body: some View {
// you can set minimumDuration to Double.greatestFiniteMagnitude if you think that
// user can hold button for such a long time
let longPress = LongPressGesture(minimumDuration: 300, maximumDistance: 300.0)
.updating($pressed) { value, state, _ in
state = value
self.configuration.trigger()
}
return configuration.label
.background(Color.green)
.opacity(pressed ? 0.5 : 1.0)
.gesture(longPress)
}
}
}
I didn't work with ButtonStyle, but tried to solve it with Composing SwiftUI Gestures. I compose TapGesture and LongPressGesture and playing with #GestureState to control .opacity of "button" (which is just Text). The result is just as you asked:
struct ComposingGestures: View {
enum TapAndLongPress {
case inactive
case pressing
var isPressing: Bool {
return self == .pressing
}
}
#GestureState var gestureState = TapAndLongPress.inactive
#State private var didPress = false
var body: some View {
let tapAndLongPressGesture = LongPressGesture(minimumDuration: 2) // if minimumDuration <= 1 gesture state returns to inactive in 1 second
.sequenced(before: TapGesture())
.updating($gestureState) { value, state, transaction in
switch value {
case .first(true), .second(true, nil):
self.didPress = true // simulation of firing action
state = .pressing
default:
state = .pressing
}
}
return VStack {
Text("action was fired!")
.opacity(didPress ? 1 : 0)
Text("Hello world!")
.gesture(tapAndLongPressGesture)
.background(Color.green)
.opacity(gestureState.isPressing ? 0.7 : 1)
}
}
}
P.S. I played only with #State var didPress to show, how to fire action. Maybe it's better to fire it only in the first case, like this:
// ...
.updating($gestureState) { value, state, transaction in
switch value {
case .first(true):
self.didPress = true // simulation of firing action
state = .pressing
case .second(true, nil):
state = .pressing
default:
state = .pressing
}
UPDATE
tried code at simulator and fixed two mistakes:
// ...
let tapAndLongPressGesture = LongPressGesture(minimumDuration: 300, maximumDistance: 300) // now button don't return opacity to 1 even if you move your finger
// ...
case .first(true), .second(true, nil):
DispatchQueue.main.async { // now there are no purple mistakes
self.didPress = true // simulation of firing action
}
state = .pressing
// ...

How do I automate the position of a UIViewController container's member view?

Scenario:
1. UIViewContainer having one (1) child UIViewController.
2. I'm using a UIStoryboard.
Goal:
To animate the entrance of the child UIViewController's view from the left edge of the container (like a UIActionSheet).
I have initially set the member view to blue.
Problem: I can correctly animate the physical coordinates but not the width constraint (nor any other constraint).
Here's my code:
import UIKit
class HamburgerContainerViewController: UIViewController {
#IBOutlet weak var containerView: UIView!
var memberView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
memberView = containerView!.subviews.first!
}
override func viewDidLayoutSubviews() {
memberView.backgroundColor = UIColor.blue
memberView.frame = CGRect(x: 0, y: 0, width: 100, height: 400)
return
}
#IBAction func DoSomething(_ sender: Any) {
memberView.translatesAutoresizingMaskIntoConstraints = false
UIView.animate(withDuration: 1.0) {
// self.memberView.frame.size.width = 345.0 // ... this works.
self.memberView.widthAnchor.constraint(equalToConstant: 25.0) // ...this makes the view slide somewhere else.
self.view.layoutIfNeeded()
}
}
}
Running my code causes the blue member view to side to the upper left of the screen leaving its UIButton & UILabel in its wake.
Here's the storyboard:
FYI: Here's a list of constraints upon the member UIViewController's view:
In answer to your question, you could either animate the constraints of the container view itself, or put a subview within the child view controller's view and animate that. But don't try to animate the frame of the child view controller's root view if that view was created and managed by IB's "container view".
So I added the view to be resized as a subview of the child view controller's view, and then I could resize that. For example, add two constraints (one that is H:[expandingView]| (which I hooked up to an #IBOutlet for wideConstraint and another that is H:[expandingView(100#750)] that I hooked up to and #IBOutlet called narrowConstraint. Then the child view controller can do:
class ChildViewController: UIViewController {
#IBOutlet var narrowConstraint: NSLayoutConstraint!
#IBOutlet var wideConstraint: NSLayoutConstraint!
#IBOutlet weak var expandingView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
wideConstraint.isActive = false // disable the wide one when the child is first loaded
}
// when I want to, I just toggle the `isActive` for the two constraints
func toggle() {
narrowConstraint.isActive = !narrowConstraint.isActive
wideConstraint.isActive = !wideConstraint.isActive
UIView.animate(withDuration: 0.5) {
self.view.layoutIfNeeded()
}
}
}
Then the parent can tell the child to toggle the size of the expandingView when the button is tapped:
#IBAction func didTapButton(_ sender: Any) {
if let child = childViewControllers.first as? ChildViewController {
child.toggle()
}
}
That yields: