macOS SwiftUI SceneView with multiple onReceive() handlers seem to block each other - swiftui

I've got a SceneView that has two .onReceive() handlers. One responds to a 30 Hz timer I set up to animate the camera movement based on some space mouse input values, the second responds to changes in the space mouse input values to update the local state.
What I'm finding is that the timer fires as you would expect, 30 times/second, until input starts coming in from the space mouse, at which point the timer's onReceive stops being called. Once I stop manipulating the space mouse, its input stops flowing.
This makes for unusable camera control. Now, I may be doing it wrong, but I'm pretty limited with what the SwiftUI SceneView has to offer, and this seems like a pretty broken behavior for Combine.
Am I missing something obvious?
struct
PerlinTerrainGeneratorView: View
{
#EnvironmentObject private var multiAxisInput : MultiAxisDevice
#State private var multiAxisState : MultiAxisState = MultiAxisState()
let updateTimer = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()
var
body: some View
{
SceneView(scene: self.scene, pointOfView: self.cameraNode, delegate: PerlinTerrainGeneratorRendererDelegate(view: self))
.onReceive(self.updateTimer) { inTimer in
if let ea = self.cameraNode
{
debugLog("roll: \(self.multiAxisState.roll)")
let qq = GLKQuaternionMakeWithAngleAndAxis(Float(self.multiAxisState.roll) * 0.01, 0, 0, 1)
let q = SCNQuaternion(qq.x, qq.y, qq.z, qq.w)
ea.localRotate(by: q)
}
}
.onReceive(self.multiAxisInput.$state) { inState in
self.multiAxisState = inState
}
}
}

Related

UIKit pinch gesture in a mixed SwiftUI / UIKit environment presents issues with scaleEffect, anchor and offset

Apple provides some elegant code for managing pinch gestures in a UIKit environment, this can be downloaded directly from Apple. In this sample code you will see three coloured rectangles that can each be panned, pinched and rotated. I will focus mainly on an issue with the pinch gesture.
My problem arises when trying to make this code work in a mixed environment by using UIKit gestures created on a UIViewRepresentable's Coordinator that talk to a model class that in turn publishes values that trigger redraws in SwiftUI. Passing data doesn't seem to be an issue but the behaviour on the SwiftUI side is not what I expect.
Specifically the pinch gesture shows an unexpected jump when starting the gesture. When the scale is bigger this quirky effect is more notorious. I also noticed that the anchor position and the previous anchor position seem to be affecting this behaviour (but I'm not sure how exactly).
Here is Apple's code for a UIKit environment:
func pinchPiece(_ pinchGestureRecognizer: UIPinchGestureRecognizer) {
guard pinchGestureRecognizer.state == .began || pinchGestureRecognizer.state == .changed,
let piece = pinchGestureRecognizer.view else {
return
}
adjustAnchor(for: pinchGestureRecognizer)
let scale = pinchGestureRecognizer.scale
piece.transform = piece.transform.scaledBy(x: scale, y: scale)
pinchGestureRecognizer.scale = 1 // Clear scale so that it is the right delta next time.
}
private func adjustAnchor(for gestureRecognizer: UIGestureRecognizer) {
guard let piece = gestureRecognizer.view, gestureRecognizer.state == .began else {
return
}
let locationInPiece = gestureRecognizer.location(in: piece)
let locationInSuperview = gestureRecognizer.location(in: piece.superview)
let anchorX = locationInPiece.x / piece.bounds.size.width
let anchorY = locationInPiece.y / piece.bounds.size.height
piece.layer.anchorPoint = CGPoint(x: anchorX, y: anchorY)
piece.center = locationInSuperview
}
A piece in Apple's code is one of the rectangles we see in the sample code. In my code a piece is a UIKit object living in a UIViewRepresentable, I call it uiView and it holds all the gestures that it responds to:
#objc func pinch(_ gesture: UIPinchGestureRecognizer) {
guard gesture.state == .began || gesture.state == .changed,
let uiView = gesture.view else {
return
}
adjustAnchor(for: gesture)
parent.model.scale *= gesture.scale
gesture.scale = 1
}
private func adjustAnchor(for gesture: UIPinchGestureRecognizer) {
guard let uiView = gesture.view, gesture.state == .began else {
return
}
let locationInUIView = gesture.location(in: uiView)
let locationInSuperview = gesture.location(in: uiView.superview)
let anchorX = locationInUIView.x / uiView.bounds.size.width
let anchorY = locationInUIView.y / uiView.bounds.size.height
parent.model.anchor = CGPoint(x: anchorX, y: anchorY)
// parent.model.offset = CGSize(width: locationInSuperview.x, height: locationInSuperview.y)
}
The parent.model refers to the model class that comes through an EnvironmentObject directly into the UIViewRepresentable struct.
In the SwiftUI side of things, ContentView looks like this (for clarity I'm just using one CustomUIView instead of the three pieces of Apple's code):
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
CustomUIView()
.frame(width: 300, height: 300)
.scaleEffect(model.scale, anchor: model.anchor)
.offset(document.offset)
}
}
As soon as you try to pinch on the CustomUIView, the rectangle jumps a little as if it would not be correctly applying an initial translation to compensate for the anchor. The scaling does appear to work according to the anchor and the offset seems to be applied correctly when panning.
One odd hint: the initial jump seems to be going in the direction of the anchor but stays half way there, effectively not reaching the right translation and making the CustomUIView jump under your fingers. As you keep on pinching closer to the previous anchor, the jump is less notorious.
Any help on this one would be greatly appreciated!

Does XCUIElement represent a query rather than a specific element?

This example is pretty contrived, but it illustrates the behavior. I know you can use .accessibilityIdentifier to uniquely identify a control, but I'm just trying to better understand the interplay between XCUIElement and XCUIElementQuery.
Let's say you have an app like this:
import SwiftUI
struct ContentView: View {
#State var showRedButton = true
var body: some View {
VStack {
if showRedButton {
Button("Click me") {
showRedButton = false
}
.background(.red)
}
else {
HStack {
Button("Click me") {
showRedButton = true
}
.background(.blue)
Spacer()
}
}
}
}
}
And you are UI testing like this:
import XCTest
final class MyAppUITests: XCTestCase {
func testExample() throws {
let app = XCUIApplication()
app.launch()
print(app.debugDescription)
// At this point, the Element subtree shows a single Button:
// Button, 0x14e40d290, {{162.3, 418.3}, {65.3, 20.3}}, label: 'Click me'
let btn = app.buttons["Click me"]
btn.tap() // <-- This tap makes the red button disappear and shows the blue button
print(app.debugDescription)
// Now, the Element subtree shows a single Button that has a different ID
// and different x-y coordinates:
// Button, 0x15dc12e50, {{0.0, 418.3}, {65.3, 20.3}}, label: 'Click me'
btn.tap() // <-- This tap now works on the blue button?? Without requerying?
print(app.debugDescription)
// The red button reappears, but with a different ID (which makes sense).
}
}
Why does the second tap work, even though it's a different control? This must mean that SwiftUI is automatically re-running the XCUIElementQuery to find the button that matches "Click me". Apparently the variable btn isn't linked to the control with the ID 0x14e40d290. Does this mean XCUIElement actually represents an XCUIElementQuery? I expected it to require me to explicitly re-run the query like this,
btn = app.buttons["Click me"]
prior to running the 2nd tap, or the tap would've said that btn was no longer available.
The final print of the Element subtree shows that the red button has a different ID now. This makes sense, because when SwiftUI redraws the red button, it's not the same instance as the last time. This is explained well in the WWDC videos. Nevertheless, at the moment I connected the variable "btn" to the control, I thought there was a tighter affiliation. Maybe UI testing has to behave this way because SwiftUI redraws controls so frequently?

Trouble with changing variables from the new iOS 16 App Intents

I have scaled down one of the Apps I am working on to use to learn how to program App Intents and utilize Siri in my apps. I will post the scaled-down version of my code below in its simplest form. This is a simple app that merely keeps a total in an #State var named counter. The app shows the current total along with 2 buttons, one button being labeled "minus" and the other labeled "add".
When someone taps the minus button, 1 is subtracted from counter if counter is greater than 0. When someone taps the plus button, 1 is added to counter as long as it is not greater than 10,000.
The buttons actually call functions called decrementCounter and incrementCounter which do the math and update the value of the state variable counter.
The app works just fine as is. Minus and plus buttons work and the view is updated to reflect the current value of counter as buttons are pushed.
The problem is when I try to use an App Intent to add or subtract from counter. In this example, I only put in two App Intents, one to add to counter and the other to have Siri tell you what the current value of counter is.
The app intent called SiriAddOne calls the same function as is used when a button is pressed, however, counter does not get incremented.
Also, the app intent SiriHowMany will always tell you the counter is zero.
It's like the App Intents are not able to access the counter variable used in the view.
I do know that the functions are being called because, in my main program where I extracted this from, the incrementCounter and decrementCounter functions do others things as well. Those other things all work when I use the App Intent to call the function, but the counter variable remains unchanged.
Hopefully, someone can tell me what I am doing wrong here or how I need to go about doing this correctly. Thank you.
import SwiftUI
import AppIntents
struct ContentView: View {
// This variable counter is the only thing that changes
// and should be what forces the view to update
#State var counter = 0
var body: some View {
VStack {
Spacer()
Text("Total")
// Displays the current value of the counter
Text(String(counter))
Spacer()
// When button is pressed call decrementCounter function
Button(action: {
decrementCounter()
}, label: {
Text("Minus")
})
Spacer()
// When button is pressed call incrementCounter function
Button(action: {
incrementCounter()
}, label: {
Text("Add")
})
Spacer()
}
.padding()
}
// subtract 1 from the counter
// when this happens the view should update to
// to reflect the new value.
func decrementCounter() {
if counter > 0 {
counter -= 1
}
return
}
// Add 1 to the counter
// when this happens the view should update to
// to reflect the new value.
func incrementCounter() {
if counter <= 9999 {
counter += 1
}
return
}
// Set up App Intent, perform action when matched
// and have siri state it has been done.
#available(iOS 16, *)
struct SiriAddOne: AppIntent {
static var title: LocalizedStringResource = "Add 1"
static var description = IntentDescription("Adds 1 to the counter")
#MainActor
func perform() async throws -> some IntentResult {
ContentView().incrementCounter()
return .result(dialog: "Okay, added 1 to counter.")
}
}
// Set up App Intent, perform action when matched
// and have siri state the current value of the counter.
#available(iOS 16, *)
struct SiriHowMany: AppIntent {
static var title: LocalizedStringResource = "How Many"
static var description = IntentDescription("How Many?")
func perform() async throws -> some IntentResult {
return .result(dialog: "You have \(ContentView().counter).")
}
}
// Defines the two shortcut phrases to be used to call the two AppIntents
#available(iOS 16, *)
struct SiriAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SiriAddOne(),
phrases: ["Add one to \(.applicationName)"]
)
AppShortcut(
intent: SiriHowMany(),
phrases: ["How many \(.applicationName)"]
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Subscribe SwiftUI View to every update of a combine publisher

Here's a simplified example of an approach I want to take, but I can't get the simple example to work.
I have a Combine publisher who's subject is a view model State:
struct State {
let a: Bool
let b: Bool
let transition: Transition?
}
The State includes a transition property. This describes the Transition that the State made in order to become the current state.
enum Transition {
case onAChange, onBChange
}
I want to use transition property to drive animations in a View subscribed to the publisher so that different transitions animate in specific ways.
View code
Here's the code for the view. You can see how it tries to use the transition to choose an animation to update with.
struct TestView: View {
let model: TestViewModel
#State private var state: TestViewModel.State
private var cancel: AnyCancellable?
init(model: TestViewModel) {
self.model = model
self._state = State(initialValue: model.state.value)
self.cancel = model.state.sink(receiveValue: updateState(state:))
}
var body: some View {
VStack(spacing: 20) {
Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
}
.onTapGesture {
model.invert()
}
}
private func updateState(state: TestViewModel.State) {
withAnimation(animation(for: state.transition)) {
self.state = state
}
}
private func animation(for transition: TestViewModel.Transition?) -> Animation? {
guard let transition = transition else { return nil }
switch transition {
case .onAChange: return .easeInOut(duration: 1)
case .onBChange: return .easeInOut(duration: 2)
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView(model: TestViewModel())
}
}
Model code
final class TestViewModel: ObservableObject {
var state = CurrentValueSubject<State, Never>(State(a: false, b: false, transition: nil))
struct State {
let a: Bool
let b: Bool
let transition: Transition?
}
enum Transition {
case onAChange, onBChange
}
func invert() {
let oldState = state.value
setState(newState: .init(a: !oldState.a, b: oldState.b, transition: .onAChange))
setState(newState: .init(a: !oldState.a, b: !oldState.b, transition: .onBChange))
}
private func setState(newState: State) {
state.value = newState
}
}
You can see in the model code that when invert() is called, two state changes occur. The model first toggles a using the .onAChange transition, and then toggles b using the .onBChange transition.
What should happen
What should happen when this is run is that each time the view is clicked, the text "AAAAAAA" and "BBBBBBB" should toggle size. However, the "AAAAAAA" text should change quickly (1 second) and the "BBBBBBB" text should change slowly (2 seconds).
What actually happens
However, when I run this and click on the view, the view doesn't update at all.
I can see from the debugger that onTapGesture { … } is called and invert() is being called on the model. Also updateState(state:) is also being called. However, TestView is not changing on screen, and body is not invoked again.
Other things I tried
Using a callback
Instead of using a publisher to send the event to the view, I've tried a callback function in the model set to the view's updateState(state:) function. I assigned to this in the init of the view with model.handleUpdate = self.update(state:). Again, this did not work. The function invert() and update(state:) were called, as expected, but the view didn't actually change.
Using #ObservedObject
I change the model to be ObservableObject with its state being #Published. I set up the view to have an #ObservedOject for the model. With this, the view does update, but it updates both pieces of text using the same animation, which I don't want. It seems that the two state updates are squashed and it only sees the last one, and uses the transition from that.
Something that did work – sort of
Finally, I tried to directly copy the model's invert() function in to the view's onTapGesture handler, so that the view updates its own state directly. This did work! Which is something, but I don't want to put all by model update logic in my view.
Question
How can I have a SwiftUI view subscribe to all states that a model sends through its publisher so that it can use a transition property in the state to control the animation used for that state change?
The way you subscribe a view to the publisher is by using .onRecieve(_:perform:), so instead of saving a cancellable inside init, do this:
var body: some View {
VStack(spacing: 20) {
Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
}
.onTapGesture {
model.invert()
}
.onReceive(model.state, perform: updateState(state:)) // <- here
}

Why is didSet not working as expected with a Binding property in SwiftUI

I have the following two properties in a child view:
is a local #State var localValue that is initialized to false
is a #Binding var parentValue property that is being toggled by a parent view.
I want to use the localValue in my code since it starts out false and then is toggled to true when the view is initialized and this starts an animation that needs to start out as false. The parentValue property gets toggled in the parent view and I want this to update my localValue property to control the animation properly. Although the parentValue is indeed toggling the localValue property does not seem to be changing with the didSet. What am I missing???
#State var localValue = false
#Binding var parentValue: Bool {
didSet {localValue = parentValue}
}
Update with more detail based on comment
If there is no way to do this because the #Binding wrapper itself is not changing so didSet is not being triggered then what I need is similar.
I would like to have a property that is initially set to false but that can be toggled from an external view so as to control an animation in the child view which changes direction based on the value being toggled.
If I could initialize the bound property in the child view to false ie if there was some way to set
#Binding var breath: Bool = false
as the starting value then each time the parent toggles the parentValue property the child view would respond properly. As it is now the parent view calls the child view with this property set to true which means the animation is fully expanded whereas I want the first step to have the animation grow for a particular duration and then reverse when the parent sets the value to false for some other duration amount.
// Whole flower
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center) // Inhale = clockwise rotation, Exhale = anticlockwise rotation
.scaleEffect(breath ? 1 : 0.2) // Inhale = upscale, Exhale = downscale
.animation( Animation.easeInOut(duration: self.stepDuration))
.opacity(breath ? 1 : 0.75)
See the code above for why it is important that when the view starts the animation is at a scale factor of 0.2 but then grows to full size before reversing when the duration has expired and the breath property has been toggled to false.
The reason didSet is not called is because the property's value is not changed. You would have to assign a new Binding to the underlying _parentValue property. Instead, you can define a new Binding property that mutates the parentValue property and calls a custom closure. An easy way to do this is to define an extension method like this:
extension Binding {
func didSet(_ closure: #escaping (Value) -> ()) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: {
self.wrappedValue = $0
closure($0)
}
)
}
}
Then when you initialize your property, you can do something like:
self.parentValue = $someBinding.didSet { localValue = $0 }