I'm trying to learn Combine with SwiftUI and I'm struggling how to update my view (from UIKit) with ObservableObject (previously BindableObject). The issue is that, obviously, method updateUIView will not fire once the #Published object sends the notification it was changed.
class DataSource: ObservableObject {
#Published var locationCoordinates = [CLLocationCoordinate2D]()
var value: Int = 0
init() {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
self.value += 1
self.locationCoordinates.append(CLLocationCoordinate2D(latitude: 52, longitude: 16+0.1*Double(self.value)))
}
}
}
struct MyView: UIViewRepresentable {
#ObservedObject var dataSource = DataSource()
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let newestCoordinate = dataSource.locationCoordinates.last ?? CLLocationCoordinate2D(latitude: 52, longitude: 16)
let annotation = MKPointAnnotation()
annotation.coordinate = newestCoordinate
annotation.title = "Test #\(dataSource.value)"
view.addAnnotation(annotation)
}
}
How to bind that locationCoordinates array to the view in such a way, that a new point is in fact added each time it refreshes?
To make sure your ObservedObject does not get created multiple times (you only want one copy of it), you can put it outside your UIViewRepresentable:
import SwiftUI
import MapKit
struct ContentView: View {
#ObservedObject var dataSource = DataSource()
var body: some View {
MyView(locationCoordinates: dataSource.locationCoordinates, value: dataSource.value)
}
}
class DataSource: ObservableObject {
#Published var locationCoordinates = [CLLocationCoordinate2D]()
var value: Int = 0
init() {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
self.value += 1
self.locationCoordinates.append(CLLocationCoordinate2D(latitude: 52, longitude: 16+0.1*Double(self.value)))
}
}
}
struct MyView: UIViewRepresentable {
var locationCoordinates: [CLLocationCoordinate2D]
var value: Int
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
print("I am being called!")
let newestCoordinate = locationCoordinates.last ?? CLLocationCoordinate2D(latitude: 52, longitude: 16)
let annotation = MKPointAnnotation()
annotation.coordinate = newestCoordinate
annotation.title = "Test #\(value)"
view.addAnnotation(annotation)
}
}
this solution worked for me but with EnvironmentObject https://gist.github.com/svanimpe/152e6539cd371a9ae0cfee42b374d7c4
I'm gonna provide a general solution for any UI/NS view representable using combine. There are performance benefits to my method.
Created an Observable Object and wrap the desired properties with
#Published wrapper
Inject the Observed object via the updateView method in the view
representable using a method you'll make in step 3
Subclass the desired view with the view model as a parameter.
Create an addViewModel method and use
combine operators/ subscribers and add them to cancellable.
Note - Works great with environment objects.
struct swiftUIView : View {
#EnvironmentObject var env : yourViewModel
...
...
UIViewRep(wm : env)
}
struct UIViewRep : UIViewRepresentable {
var wm : yourViewModel
func makeUIView {
let yv = yourView()
yv.addViewModel(wm)
return yv
}}
class yourView : UIView {
var viewModel : yourViewModel?
var cancellable = Set<AnyCancellable>()
...
...
func addViewModel( _ wm : yourViewModel) {
self.viewModel = wm
self.viewModel?.desiredProperty
.sink(receiveValue: { [unowned self] w in
print("Make changes with ", w)
}).store(in: &cancellable)
}
}
Related
I am using SwiftUI with RealityKit. As displayed in the code below, I have a plane entity that when tapped simply prints the name of the entity. What approach should I take toward navigating to a new view when I tap the entity? It would be preferable to navigate as with a navigation link in a normal view, but if that is not possible then perhaps a fullScreenCover?
ARViewContainer.swift:
class Coordinator: NSObject {
weak var view: ARView?
#objc func handleTap(_ recognizer: UITapGestureRecognizer) {
guard let view = self.view else { return }
let tapLocation = recognizer.location(in: view)
if let entity = view.entity(at: tapLocation) as? ModelEntity {
print(entity.name)
}
}
}
struct ARViewContainer: UIViewRepresentable {
typealias UIViewType = ARView
func makeUIView(context: Context) -> ARView{
let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)
context.coordinator.view = arView
arView.addGestureRecognizer(UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap)))
arView.scene.anchors.removeAll()
let anchor = AnchorEntity()
let plane = MeshResource.generatePlane(width: 1, height: 1)
var material = UnlitMaterial()
material.color = .init(tint: .white,
texture: .init(try! .load(named: "instagram")))
let planeEntity = ModelEntity(mesh: plane, materials: [material])
planeEntity.generateCollisionShapes(recursive: true)
planeEntity.name = "Plane Entity"
planeEntity.position.z -= 1.0
planeEntity.setParent(anchor)
arView.scene.addAnchor(anchor)
return arView
}
func updateUIView(_ uiView: ARView, context: Context){
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
ContentView.swift
struct ContentView: View {
#State var open = false
var body: some View {
NavigationView{
ZStack {
ARViewContainer()
.ignoresSafeArea(.all)
}
}
}
}
View I want to navigate to:
struct TestView : View {
var body : some View {
VStack{
Text("Test View")
}
}
}
Manage the state of the view in an observable object and modify it from your AR view.
struct ContentView: View {
#ObservedObject var settings = Settings.shared
var body: some View {
NavigationView {
ZStack {
ARViewContainer()
.ignoresSafeArea(.all)
NavigationLink("", isActive: $settings.shouldOpenDetailsView) {
TestView()
}
}
}
}
}
class Settings: ObservableObject {
static let shared = Settings()
#Published var shouldOpenDetailsView = false
}
class Coordinator: NSObject {
weak var view: ARView?
#objc func handleTap(_ recognizer: UITapGestureRecognizer) {
guard let view = self.view else { return }
let tapLocation = recognizer.location(in: view)
if let entity = view.entity(at: tapLocation) as? ModelEntity {
Settings.shared.shouldOpenDetailsView = true
}
}
}
I have a nested View Model class WatchDayProgramViewModel as an ObservableObject. Within WatchDayProgramViewModel, there is a WorkoutModel that is a child class. I want to detect any updates in the currentHeartRate to trigger data transfer to iPhone.
Hence, I tried from ContentView using WatchDayProgramViewModel as an EnvironmentObject and detecting changes in WorkoutModel via onChange() method. But it seems that SwiftUI views does not detect any property changes in WorkoutModel.
I understand that this issue could be due to ObservableObject not detecting changes in child/nested level of classes, and SO answer (SwiftUI change on multilevel children Published object change) suggests using struct instead of class. But changing WorkoutModel to struct result in various #Published properties and functions to show error.
Is there any possible way to detect changes in child View Model from the ContentView itself?
ContentView
struct ContentView: View {
#State var selectedTab = 0
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
var body: some View {
NavigationView {
TabView(selection: $selectedTab) {
WatchControlView().id(0)
NowPlayingView().id(1)
}
.environmentObject(watchDayProgramVM)
.onChange(of: self.watchDayProgramVM.workoutModel.currentHeartRate) { newValue in
print("WatchConnectivity heart rate from contentView \(newValue)")
}
}
}
WatchDayProgramViewModel
class WatchDayProgramViewModel: ObservableObject {
#Published var workoutModel = WorkoutModel()
init() {
}
}
WorkoutModel
import Foundation
import HealthKit
class WorkoutModel: NSObject, ObservableObject {
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
#Published var currentHeartRate: Double = 0
#Published var workout: HKWorkout?
//Other functions to start/run workout hidden
func updateForStatistics(_ statistics: HKStatistics?) {
guard let statistics = statistics else {
return
}
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
self.currentHeartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
default:
return
}
}//end of dispatchqueue
}// end of function
}
extension WorkoutModel: HKLiveWorkoutBuilderDelegate {
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateForStatistics(statistics)
}
}
}
Try to change
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
with
#ObservedObject var watchDayProgramVM = WatchDayProgramViewModel()
Figure it out. Just had to create another AnyCancellable variable to call objectWillChange publisher.
WatchDayProgramViewModel
class WatchDayProgramViewModel: ObservableObject {
#Published var workoutModel = WorkoutModel()
var cancellable: AnyCancellable?
init() {
cancellable = workoutModel.objectWillChange
.sink { _ in
self.objectWillChange.send()
}
}
}
While I have provided my answer, that worksaround with viewmodels, I would love to see/get advice on other alternatives.
Hi first off I'm very new to swift and programing (coming from design field).
I'm trying to update doesNotificationsExist based on posts.count
I'm getting true inside the Api().getPosts {}
Where I print the following:
print("Api().getPosts")
print(doesNotificationExist)
but outside (in the loadData() {}) I still get false and not the #Publihed var doesNotificationExist:Bool = false doesn't update.
Please help me out, I would really appreciate some guidance to what I'm doing wrong and what I need to do.
Here is my code:
import SwiftUI
import Combine
public class DataStore: ObservableObject {
#Published var posts: [Post] = []
#Published var doesNotificationExist:Bool = false
init() {
loadData()
startApiWatch()
}
func loadData() {
Api().getPosts { [self] (posts) in
self.posts = posts
if posts.count >= 1 {
doesNotificationExist = true
}
else {
doesNotificationExist = false
}
print("Api().getPosts")
print(doesNotificationExist)
}
print("loadData")
print(doesNotificationExist)
}
func startApiWatch() {
Timer.scheduledTimer(withTimeInterval: 60, repeats: true) {_ in
self.loadData()
}
}
View where I'm trying to set an image based on store.doesNotificationsExist
StatusBarController:
import AppKit
import SwiftUI
class StatusBarController {
private var statusBar: NSStatusBar
private var statusItem: NSStatusItem
private var popover: NSPopover
#ObservedObject var store = DataStore()
init(_ popover: NSPopover)
{
self.popover = popover
statusBar = NSStatusBar.init()
statusItem = statusBar.statusItem(withLength: 28.0)
statusItem.button?.action = #selector(togglePopover(sender:))
statusItem.button?.target = self
if let statusBarButton = statusItem.button {
let itemImage = NSImage(named: store.doesNotificationExist ? "StatusItemImageNotification" : "StatusItemImage")
statusBarButton.image = itemImage
statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0)
statusBarButton.image?.isTemplate = true
statusBarButton.action = #selector(togglePopover(sender:))
statusBarButton.target = self
}
}
`Other none relevant code for the question`
}
It’s a closure and hopefully the #escaping one. #escaping is used to inform callers of a function that takes a closure that the closure might be stored or otherwise outlive the scope of the receiving function. So, your outside print statement will be called first with bool value false, and once timer is completed closure will be called changing your Bool value to true.
Check code below -:
import SwiftUI
public class Model: ObservableObject {
//#Published var posts: [Post] = []
#Published var doesNotificationExist:Bool = false
init() {
loadData()
// startApiWatch()
}
func loadData() {
getPost { [weak self] (posts) in
//self.posts = posts
if posts >= 1 {
self?.doesNotificationExist = true
}
else {
self?.doesNotificationExist = false
}
print("Api().getPosts")
print(self?.doesNotificationExist)
}
print("loadData")
print(doesNotificationExist)
}
func getPost(completion:#escaping (Int) -> ()){
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) {_ in
completion(5)
}
}
}
struct Test1:View {
#ObservedObject var test = Model()
var body: some View{
Text("\(test.doesNotificationExist.description)")
}
}
I have this ViewModel that uses combine along with a timer and I want this ViewModel to update a LottieView and filename with a new animation. When the timer counts down I want it to publish and send specific strings, these strings will be the json Lottie filenames. When my ContentView receives these filenames I want it to dynamically update the LottieViews animation.
So I made an #State variable called name inside ContentView and made that equal to the received value that is passed in. However, what confuses me is that the filename that is being published and sent from the ViewModels timer at the 10 second mark is suppose to be received and used inside LottieView(filename: name).
However this LottieView instantly runs this file when I launch the app. How so? The only place that filename exists in the whole app is when the timer reaches 10 seconds and it shouldn't even exist when LottieView(name) is called. It also ignores the previous file name that should have been run at the 19 second mark. If I were to ignore LottieView(name) all together and run a Text View instead, so in this case Text(name), when I run the app the Text properly changes when the timer reaches 10.
So how come LottieView(name) runs like this? I verified these files properly match their animations as well.
import SwiftUI
import Combine
class ViewModel: ObservableObject {
var anyCancellable: AnyCancellable?
let publish = PassthroughSubject<String, Never>()
private var timer: Timer?
private var scheduleTime = 20
init() {
fire()
anyCancellable = publish.sink { str in
print("Value that is being passed over: \(str)")
}
}
func fire() {
print("Fire timer")
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
self.scheduleTime -= 1
print(self.scheduleTime)
if self.scheduleTime == 19 {
self.publish.send("13865-sign-for-error-flat-style")
}
if self.scheduleTime == 10 {
self.publish.send("4174-unlock-to-premium")
timer.invalidate()
}
}
}
}
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var vm = ViewModel()
#State var name: String = ""
var body: some View {
VStack {
LottieView(filename: $name)
Text(name)
}
.onReceive(vm.publish, perform: { value in
print("passed over : \(value)")
name = value
print(name)
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
typealias UIViewType = UIView
#Binding var filename: String
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView(frame: .zero)
let animationView = AnimationView()
let animation = Animation.named(filename)
animationView.animation = animation
animationView.contentMode = .scaleAspectFit
animationView.play()
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: view.widthAnchor),
animationView.heightAnchor.constraint(equalTo: view.heightAnchor),
animationView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
animationView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
}
}
After a day of searching, without finding an answer, I probably came up with a not very correct solution, but it seems to work(saving temp fileName and after update LottieView checked name):
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
typealias UIViewType = UIView
var filename: String
var animationView = AnimationView()
let isPaused: Bool
var needUpdate: Bool = false {
didSet {
if needUpdate {
let animation = Animation.named(filename)
animationView.animation = animation
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.play()
needUpdate = false
}
}
}
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView(frame: .zero)
let animation = Animation.named(filename)
animationView.animation = animation
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: view.widthAnchor),
animationView.heightAnchor.constraint(equalTo: view.heightAnchor),
])
animationView.play()
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
let tempFileName = context.coordinator.parent.filename
DispatchQueue.main.async {
context.coordinator.parent.filename = filename
if tempFileName != filename {
context.coordinator.parent.needUpdate = true
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: LottieView
init(_ parent: LottieView) {
self.parent = parent
}
}
}
I am using SwiftUI to display a map and if user tapped on an annotation, it pops up a detail view in the VStack. I have made the map view and inserted annotations in another SwiftUI file. I also made the detail view.
How can I access the annotations of that map in the main view file to define a .tapaction for them to use it for the detailed view?
I tried defining the view as MKMapView but it is not possible to do it for a UIViewRepresentable inside another SwiftUI view.
The main view (ContentView) code is:
struct ContentView: View {
#State private var chosen = false
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: chosen ? 600:nil)
.tapAction {
withAnimation{ self.chosen.toggle()}
}
if chosen {
ExtractedView()
}
}
}
}
The MapView code is:
struct MapView : UIViewRepresentable {
#State private var userLocationIsEnabled = false
var locationManager = CLLocationManager()
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
view.showsUserLocation = true
.
.
.
let sampleCoordinates = [
CLLocation(latitude: xx.xxx, longitude: xx.xxx),
CLLocation(latitude: xx.xxx, longitude: xx.xxx),
CLLocation(latitude: xx.xxx, longitude: xx.xxx)
]
addAnnotations(coords: sampleCoordinates, view: view)
}
}
}
I expect to be able to access map view annotations and define tapaction in another view.
In SwiftUI DSL you don't access views.
Instead, you combine "representations" of them to create views.
A pin can be represented by an object - manipulating the pin will also update the map.
This is our pin object:
class MapPin: NSObject, MKAnnotation {
let coordinate: CLLocationCoordinate2D
let title: String?
let subtitle: String?
let action: (() -> Void)?
init(coordinate: CLLocationCoordinate2D,
title: String? = nil,
subtitle: String? = nil,
action: (() -> Void)? = nil) {
self.coordinate = coordinate
self.title = title
self.subtitle = subtitle
self.action = action
}
}
Here's my Map, which is not just UIViewRepresentable, but also makes use of a Coordinator.
(More about UIViewRepresentable and coordinators can be found in the excellent WWDC 2019 talk - Integrating SwiftUI)
struct Map : UIViewRepresentable {
class Coordinator: NSObject, MKMapViewDelegate {
#Binding var selectedPin: MapPin?
init(selectedPin: Binding<MapPin?>) {
_selectedPin = selectedPin
}
func mapView(_ mapView: MKMapView,
didSelect view: MKAnnotationView) {
guard let pin = view.annotation as? MapPin else {
return
}
pin.action?()
selectedPin = pin
}
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
guard (view.annotation as? MapPin) != nil else {
return
}
selectedPin = nil
}
}
#Binding var pins: [MapPin]
#Binding var selectedPin: MapPin?
func makeCoordinator() -> Coordinator {
return Coordinator(selectedPin: $selectedPin)
}
func makeUIView(context: Context) -> MKMapView {
let view = MKMapView(frame: .zero)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.removeAnnotations(uiView.annotations)
uiView.addAnnotations(pins)
if let selectedPin = selectedPin {
uiView.selectAnnotation(selectedPin, animated: false)
}
}
}
The idea is:
The pins are a #State on the view containing the map, and are passed down as a binding.
Each time a pin is added or removed, it will trigger a UI update - all the pins will be removed, then added again (not very efficient, but that's beyond the scope of this answer)
The Coordinator is the map delegate - I can retrieve the touched MapPin from the delegate methods.
To test it:
struct ContentView: View {
#State var pins: [MapPin] = [
MapPin(coordinate: CLLocationCoordinate2D(latitude: 51.509865,
longitude: -0.118092),
title: "London",
subtitle: "Big Smoke",
action: { print("Hey mate!") } )
]
#State var selectedPin: MapPin?
var body: some View {
NavigationView {
VStack {
Map(pins: $pins, selectedPin: $selectedPin)
.frame(width: 300, height: 300)
if selectedPin != nil {
Text(verbatim: "Welcome to \(selectedPin?.title ?? "???")!")
}
}
}
}
}
...and try zooming/tapping the pin on London, UK :)