I have this class:
class MyObject:ObservableObject, Hashable {
static func == (lhs: PageView, rhs: PageView) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
#Published var visible:Bool
#Published var myObject:MyObject
let id = UUID()
init(_ myObject:MyObject) {
self.myObject = myObject
self.isVisible = false
}
}
and this model
class MyModel:ObservableObject {
#Published var myObjects = [MyObject]()
...
}
later in the app I have this:
#ObservedObject private var myModel:MyModel
init(_ myModel:MyModel) {
self.myModel = myModel
}
ScrollView(.vertical, showsIndicators: false) {
ForEach(model.myObjects, id: \.self) { object in
MyPanel(object)
.opacity(object.isVisible ? 1 : 0.6)
.allowsHitTesting(object.isVisible)
}
}
the objects appear semi transparent initially. I change the isVisible property of an object, it continues transparent on the scrollView.
How do I solve that?
MyModel will only publish changes if the myObjects array property is changed. From MyModel's point of view, it has an array of object references. If you change a property on one of those objects, then nothing has changed, it still has the same array of the same object references.
MyObject is an observable object, but nothing is observing it.
You should make MyPanel hold the object that you are passing it as an observable object, and have it set its own opacity and hit testing rather than have it done at the scroll view level. This way only the leaf view will re-render rather than the entire scroll view.
Related
From an architecture point of view, is it a good idea to use Published properties to control UI elements in a SwiftUI view hierarchy?
Consider the following problem,
I have a store as such
#MainActor final class SomeStore: ObservableObject {
#Published var shouldShowSuccessAlert: Boolean = false
#Published var someData: String = false
//Called by some networking library
public func networkRequestDidComplete(with data: String) {
self.someData = data
self.shouldShowSuccessAlert = false
}
}
//App
struct SomeApp: App {
#StateObject private var someStore: SomeStore = SomeStore()
var body: some Scene {
WindowGroup {
SomeView(shouldShowSuccessAlert: self.someStore.shouldShowSuccessAlert)
}
}
}
//View
struct SomeView: View {
#ObservedObject private var someStore: SomeStore
var body: some View {
if self.someStore.shouldShowSuccessAlert {
//some arbitrary alertView
AlertView(onComplete: {
self.someStore.shouldShowSuccessAlert = false
})
}
}
}
The only problem I can see is that changing this one Boolean will cause the entire view hierarchy to re-render. If SomeView has a deep view hierarchy, all of its descendants will be re-rendered. In React, for example, there is a concept of a PureComponent that you can use to prevent re-renders of views (called components in React) only if the properties they use change (by using reference equality checks) in order to improve performance. Is there something similar in SwiftUI?
Are there any other patterns that can be used like absorbing this boolean into the view's state and then controlled locally within the view?
I've created a SpriteKit Scene file and the corresponding SKScene object. That scene object includes an #Binding property.
When I use SpriteView in my SwiftUI file, I can't figure out how to initialize the scene so that it loads from the sks file and also assigns a binding.
I'm wanting something like this:
class MyScene: SKScene {
#Binding var foo: CGFloat
init(foo: Binding<CGFloat>) {
_foo = foo
super.init(fileNamed: "MyScene")
}
}
That doesn't work though because init(fileNamed:) is a convenience initializer, not a designated initializer.
A possible workaround using ObservableObject to subscribe to foo. I pass a fileName so I have an excuse to invoke my convenience initializer.
class MyScene: SKScene, ObservableObject {
#Published var foo: CGFloat = 0
convenience init(fileName: String) {
self.init(fileNamed: fileName)!
}
}
And then hold a reference to it on your view (or elsewhere).
struct MyView: View {
#ObservedObject var scene = MyScene(fileName: "MyScene")
var body: some View {
VStack {
FooView($scene.foo)
SpriteView(scene: scene)
}
}
}
Let’s say I have a model class Ball, that conforms to the ObservableObject protocol, and I define an optional instance of it (var ball: Ball?).
Is there a way to trigger a NavigationLink to display its destination view based on setting the value of the optional instance? So when I self.ball = Ball(), a NavigationLink will trigger?
One problem seems to be that an optional (Type?) can’t be an #ObservedObject.
The other problem seems to be that the isActive: parameter for NavigationLink can only take a Binding<Bool>.
/// Contrived minimal example to illustrate the problem.
include SwiftUI
class Ball: ObservableObject {
#Published var colour: String = "red"
// ...
}
struct ContentView: View {
// this won’t trigger view updates when it’s set because it’s not observed:
var ball: Ball?
// this line won’t compile:
#ObservedObject var observedBall: Ball?
// 🛑 Property type 'Ball?' does not match that of the
// 'wrappedValue' property of its wrapper type 'ObservedObject'
var body: some View {
NavigationView {
VStack {
// I want this to navigate to the ballView when isActive becomes true,
// but it won’t accept the test of nil state on the optional value:
NavigationLink(
destination: BallView(ball: self.ball), isActive: self.ball != nil
) {
EmptyView()
} // 🛑 compiler error because `self.ball != nil` isn’t valid for `isActive:`
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff here ...
}
So far, the best workaround I have for the above limitations involves:
defining another ObservableObject-conforming class as a wrapper around an Optional Ball instance,
adding a #State var ballIsSet = false (or #Binding var ballIsSet: Bool) variable to my view,
passing in that ballIsSet boolean variable to the wrapper class object,
then having a didSet function on the wrapped Ball variable that updates the passed in boolean.
Phew!
Hopefully someone knows a simpler/better way to do this…
// Still a somewhat contrived example, but it illustrates the points…
class ObservableBall: ObservableObject {
// a closure to call when the ball variable is set
private var whenSetClosure: ((Bool) -> Void)?
#Published var ball: Ball? {
didSet {
// if the closure is assigned, call it when the ball gets set
if let setClosure = self.whenSetClosure {
setClosure(self.ball != nil)
}
}
}
init(_ ball: Ball? = nil, whenSetClosure: ((Bool) -> Void)? = nil) {
self.ball = ball
self.whenSetClosure = whenSetClosure
}
}
struct ContentView: View {
#ObservedObject var observedBall = ObservableBall()
#State var ballIsSet = false
var body: some View {
NavigationView {
VStack {
// Navigate to the ballView when ballIsSet becomes true:
NavigationLink(
// we can force unwrap observedBall.ball! here
// because this only gets called if ballIsSet is true:
destination: BallView(ball: self.observedBall.ball!),
isActive: $ballIsSet
) {
EmptyView()
}
// Button user taps to set the `ball`,
// triggering the BallView to be shown.
Button(
action: { self.observedBall.ball = Ball() },
label: { Text("Show Ball") }
)
}
.onAppear {
observedBall.whenSetClosure = { isSet in
self.ballIsSet = isSet
}
}
}
}
}
You can use the init(get:set:) initializer of the Binding to activate the destination view based on the optional instance or on an arbitrary conditional logic. For example:
struct Ball {
var colour: String = "red"
// ...
}
struct ContentView: View {
#State private var ball: Ball?
var body: some View {
NavigationLink(isActive: Binding<Bool>(get: {
ball != nil
}, set: { _ in
ball = nil
})) {
DestinationView()
} label: {
EmptyView()
}
}
}
I've used struct Ball instead of class Ball: ObservableObject, since #ObservedObject ball: Ball? represents a value semantic of type Optional<Ball>, thus cannot be combined with #ObservedObject or #StateObject, which are property wrappers for reference types. The value types (i.e., Optinal<Ball>) can be used with #State or #Binding, but this is most likely not what you want with an optional observable object.
I encountered similar problem and the way I tried to get my NavigationLink to render based on a nullable object is to wrap around the NavigationLink if a optional binding to that optional object, then attach the onAppear callback to modify the isActive binding boolean to turn on the navigation link (so that it become active after it added to the view hierarchy and thus keep the transition animation)
class ObservableBall: ObservableObject {
#Published var ball: Ball?
#Published var showBallView = false
}
struct ContentView: View {
#ObservedObject var observedBall: Ball
var body: some View {
NavigationView {
VStack {
if let ball = observedBall.ball {
NavigationLink(
destination: BallView(ball: ball),
isActive: $observedBall.showBallView)
{
EmptyView()
}
.onAppear {
observedBall.showBallView = true
}
}
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.observedBall.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff here ...
}
I use Combine in viewModels to update the views. But if I store the AnyCancellable objects into a set of AnyCancellable, the deinit method is never called. I use the deinit to cancel all cancellables objects.
struct View1: View {
#ObservedObject var viewModel:ViewTextModel = ViewTextModel()
#Injected var appActions:AppActions
var body: some View {
VStack {
Text(self.viewModel.viewText)
Button(action: {
self.appActions.goToView2()
}) {
Text("Go to view \(self.viewModel.viewText)")
}
}
}
}
class ViewTextModel: ObservableObject {
#Published var viewText: String
private var cancellables = Set<AnyCancellable>()
init(state:AppState) {
// initial state
viewText = "view \(state.view)"
// updated state
state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
}
deinit {
cancellables.forEach { $0.cancel() }
}
}
Each time the view is rebuilt, a new viewmodel is instantiated but the old one is not destroyed. viewText attribute is updated on each instance with state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
If I don't store the cancellable object in the set, deinit is called but viewText is not updated if the state's changed for the current view.
Do you have an idea of how to manage the update of the state without multiplying the instances of the viewmodel ?
Thanks
You could use sink instead of assign:
state.$view
.removeDuplicates()
.sink { [weak self] in self?.viewText = $0 }
.store(in: &cancellables)
But I question the need for Combine here at all. Just use a computed property:
class ViewTextModel: ObservableObject {
#Published var state: AppState
var viewText: String { "view \(state.view)" }
}
UPDATE
If your deployment target is iOS 14 (or macOS 11) or later:
Because you are storing to an #Published, you can use the assign(to:) operator instead. It manages the subscription for you without returning an AnyCancellable.
state.$view
.removeDuplicates()
.map { "view \($0)" }
.assign(to: &$viewText)
// returns Void, so nothing to store
Just getting started with SwiftUI.
I have a GoogleMapsView in a ContentView
using the CLLocationManager I capture events in the AppDelegate or SceneDelegate class by means of extending them with CLLocationManagerDelegate.
How can I invoke a method in the GoogleMapsView from the AppDelegate or SceneDelegate?
In this instance I want to call the .animate method when the location change event is sent to the AppDelegate instance via the CLLocationManagerDelegate, but the question is really more generic.
I made and implementation of CLLocationManager and MKMapView and it is almost the same as maps, hope it will help you:
Short answer: declaring a #Binding var foo: Any you will be able to make changes inside GoogleMapView every time that foo changes, in this case foo is your location, so you can call animate every time foo is updated.
Long answer:
First I created a Mapview that conforms UIViewRepresentable protocol, just as you did, but adding a #Binding variable, this is my "trigger".
MapView:
struct MapView: UIViewRepresentable {
#Binding var location: CLLocation // Create a #Binding variable that keeps the location where I want to place the view, every time it changes updateUIView will be called
private let zoomMeters = 400
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
let mapView = MKMapView(frame: UIScreen.main.bounds)
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
//When location changes, updateUIView is called, so here I move the map:
let region = MKCoordinateRegion(center: location.coordinate,
latitudinalMeters: CLLocationDistance(exactly: zoomMeters)!,
longitudinalMeters: CLLocationDistance(exactly: zoomMeters)!)
mapView.setRegion(mapView.regionThatFits(region), animated: true)
}
}
Then I placed my MapView in my ContentView, passing a location argument, which I will explain next:
ContentView:
struct ContentView: View {
#ObservedObject var viewModel: ContentViewModel
var body: some View {
VStack {
MapView(location: self.$viewModel.location)
}
}
}
In my ViewModel, I handle location changes using a delegate, here is the code with more details in comments:
class ContentViewModel: ObservableObject {
//location is a Published value, so the view is updated every time location changes
#Published var location: CLLocation = CLLocation.init()
//LocationWorker will take care of CLLocationManager...
let locationWorker: LocationWorker = LocationWorker()
init() {
locationWorker.delegate = self
}
}
extension ContentViewModel: LocationWorkerDelegate {
func locationChanged(lastLocation: CLLocation?) {
//Location changed, I change the value of self.location, it is a #Published value so it will refresh the #Binding variable inside MapView and call MapView.updateUIView
self.location = CLLocation.init(latitude: lastLocation!.coordinate.latitude, longitude: lastLocation!.coordinate.latitude)
}
}
And finally here is LocationWorker which take cares of CLLocationManager():
class LocationWorker: NSObject, ObservableObject {
private let locationManager = CLLocationManager()
var delegate: LocationWorkerDelegate?
let objectWillChange = PassthroughSubject<Void, Never>()
#Published var locationStatus: CLAuthorizationStatus? {
willSet {
objectWillChange.send()
}
}
#Published var lastLocation: CLLocation? {
willSet {
objectWillChange.send()
}
}
override init() {
super.init()
self.locationManager.delegate = self
//...
}
}
protocol LocationWorkerDelegate {
func locationChanged(lastLocation: CLLocation?)
}
extension LocationWorker: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
self.lastLocation = location
//When location changes: I use my delegate ->
if delegate != nil {
delegate!.locationChanged(lastLocation: lastLocation)
}
}
}
Instead of calling a View method directly from outside, you should revise your logic a bit and just change some kind of a state somewhere and let the View update itself. Take a look at this algorithm:
The classic (and worst) way:
Location changed
Delegate method called in the app delegate (Better refactor to else where)
App delegate calls a method directly on the view (You should pass a reference to that view all the way up to the app delegate)
Although the above algorithm is what you are looking for originally, It isn't the best way and I don't recommend it at all! But it will work 🤷🏻♂️
The SwiftUI way:
Location changed
Delegate method called in the responsible object (maybe a singleton location location manager instance 🤷🏻♂️)
Location manager updates a State somewhere. (maybe an ObservedObject variable inside itself or an EnvironmentObject or etc.)
All views that subscribed for changes of that property will notify about the changes
All notified views will update themselves.
This is how it should be done. But there are more than just one way to implement this and you should consider your preferences to pick the best for you.