Refresh SwiftUI View Based on Local Notification - ios

I have the following code:
#State private var isDataImported: Bool = false
init() {
NotificationCenter.default.addObserver(forName: .onDataImported, object: nil, queue: nil) { [self] notification in
print("Data has been imported...")
DispatchQueue.main.async {
self.isDataImported = true
print(self.isDataImported) // prints out false
}
}
}
I can debug and see that the notification is being fired. The line "Data has been imported" is printing. I want to update self.isDataImported property and then refresh my view.
if isDataImported {
ShowDataView()
} else {
ProgressView()
}
But the view ShowDataView never shows up since isDataImported is always false. What am I missing?

If you are using SwiftUI, you should consider using all the reactive tools available to you and subscribe to Publishers.
SwiftUI has a method onReceive(_:perform:) which takes a Publisher and closure to run when it receives an event from this publisher. You can use that to listen for your notification and change any state based on that.
Doing it this way, rather than creating and manually subscribing to a publisher in .onAppear, means that you don't need to retain the cancellable.
import SwiftUI
import Combine
extension NSNotification.Name {
static let onDataImported = Notification.Name("onDataImported")
}
struct ContentView: View {
#State private var dataReceived = false
var body: some View {
VStack {
Text(dataReceived ? "Received" : "Waiting")
.padding()
Button("Simulate Notification") {
NotificationCenter.default.post(name: .onDataImported, object: nil)
}
.padding()
}
.onReceive(NotificationCenter.default.publisher(for: .onDataImported), perform: { _ in
self.dataReceived = true
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I've added a button to send the notification so you can see it being received.

The transitive nature of SwiftUI views makes doing things like trying to capture a reference to self in your view's init problematic. Here are a couple solutions:
Option 1
Keep everything in the View:
struct ContentView: View {
#State private var isDataImported: Bool = false
#State private var cancellable : AnyCancellable?
var body: some View {
Group {
if isDataImported {
Text("Has data")
} else {
Text("Does not have data")
}
}.onAppear {
cancellable = NotificationCenter.default.publisher(for: .onDataImported)
.receive(on: RunLoop.main)
.sink { notification in
self.isDataImported = true
}
NotificationCenter.default.post(name: .onDataImported, object: nil)
}
}
}
Option 2
This is usually what I'd do, moving it to a view model, so that you can keep onAppear a little cleaner. Because the view model is a class and have a reliable, reference-based lifetime, the assignment to self is less problematic:
class ViewModel: ObservableObject {
#Published var isDataImported: Bool = false
private var cancellable : AnyCancellable?
init() {
cancellable = NotificationCenter.default.publisher(for: .onDataImported)
.receive(on: RunLoop.main)
.sink { notification in
self.isDataImported = true
}
}
}
struct ContentView : View {
#StateObject var viewModel = ViewModel()
var body: some View {
Group {
if viewModel.isDataImported {
Text("Has data")
} else {
Text("Does not have data")
}
}.onAppear {
NotificationCenter.default.post(name: .onDataImported, object: nil)
}
}
}

Related

onReceive callback not executing

So I am working on a view in SwiftUI which will update its state when an event is published.
The view looks like this:
struct MyView: View {
#EnvironmentObject var dataSource: DataSource
#State var data: [Model] = []
func refreshData() {
self.data = dataSource.getData()
}
var body: some View {
VStack {
List(self.data) { model in
Row(model: model)
}
}
.onAppear {
self.refreshData()
}
.onReceive(self.dataSource.didUpdate) { _ in
print("on receive")
self.refreshData()
}
}
}
class DataSource: ObservableObject {
var didUpdate: PassthroughSubject<Model,Never> ...
}
So with this setup, the onAppear block is called and works as expected. But the onReceive callback is never called. I have been able to verify that .send is being called correctly on the DataSource.didUpdate subject, but it appears the subscriber is not being notified.
Is there something I am missing to make this work?
As you are correctly declaring your DataSource as an observable object class, what you need now is to use the #Published property wrapper on you didUpdate variable. Then, with SwiftUI you can listen to it using .onChange(of:) { }. Just note that it does not work with computed vars: in such case, use the computed var to update the published var. Also, I assume you are correctly injecting your model instance in the environment (otherwise it will never work).
Like this:
struct MyView: View {
#EnvironmentObject var dataSource: DataSource // Remember to inject the specific instance in the environment
#State var data: [Model] = []
func refreshData() {
self.data = dataSource.getData()
}
var body: some View {
VStack {
List(self.data) { model in
Row(model: model)
}
}
.onAppear {
self.refreshData()
}
// .onReceive(self.dataSource.didUpdate) { _ in
// print("on receive")
.onChange(of: dataSource.didUpdate) { _ in // Listen to changes in ObservableObject property that publishes
print("on change")
self.refreshData()
}
}
}
class DataSource: ObservableObject {
// Note the property wrapper, but it does not work with computed vars:
// in such case, use the computed var to update the published var.
// Also: update it only in the main thread!
#Published var didUpdate: PassthroughSubject<Model,Never> ...
}

SwiftUI: How to initialize a new StateObject in a parent view?

I have an app architecture similar to the below (simplified) code. I use a WorkoutManager StateObject which I initialize in the set up view, then pass down to its children via EnvironmentObject. The problem is that upon dismissing the .sheet there isn't any life cycle event which initializes a new WorkoutManager, which I need in order to be able to start new workouts consecutively. How in this example below can I give WorkoutView the ability to reinitialize WorkoutManager so that it is a clean object?
import SwiftUI
import HealthKit
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
//Here I want to initialize a new WorkoutManager to clear out the previous workout's state, how?
} label: {
Text("End Workout")
}
}
}
As mentioned in the comments already, the route you probably want to take is reseting the state within the same WorkoutManager. You wouldn't be able to assign a new object to a #StateObject anyway -- you'll end up with compiler errors because of the View's immutable self.
Secondly, I'd suggest that you probably don't want to rely on the Button in your WorkoutView to do this. For example, if the user dismissed the sheet by swiping, that wouldn't get called. Instead, you could listen for the sheet's state in onChange (another method would be using the onDismiss parameter of sheet):
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
func resetState() {
//do whatever you need to do to reset the state
print("Reset state")
}
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
.onChange(of: showingWorkoutView) { newValue in
if !newValue {
workoutManager.resetState()
}
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
} label: {
Text("End Workout")
}
}
}

SwiftUI #FocusState - how to give it initial value

I am excited to see the TextField enhancement: focused(...): https://developer.apple.com/documentation/swiftui/view/focused(_:)
I want to use it to show a very simple SwitfUI view that contains only one TextField that has the focus with keyboard open immediately. Not able to get it work:
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
.onAppear {
isFocused = true
}
// ...
Anything wrong? I have trouble to give it default value.
I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
The theory I have behind the bug is that at the time of onAppear the TextField is not ready to become first responder, so isFocused = true and iOS calls becomeFirstResponder behind the scenes, but it fails (ex. the view hierarchy is not yet done setting up).
struct MyView: View {
#State var text: String
#FocusState private var isFocused: Bool
var body: some View {
Form {
TextEditor(text: $text)
.focused($isFocused)
.onChange(of: isFocused) { isFocused in
// this will get called after the delay
}
.onAppear {
// key part: delay setting isFocused until after some-internal-iOS setup
DispatchQueue.main.asyncAfter(deadline: .now()+0.5) {
isFocused = true
}
}
}
}
}
I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
It also works without delay.
DispatchQueue.main.async {
isFocused = true
}
//This work in iOS 15.You can try it.
struct ContentView: View {
#FocusState private var isFocused: Bool
#State private var username = "Test"
var body: some View {
VStack {
TextField("Enter your username", text: $username)
.focused($isFocused).onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isFocused = true
}
}
}
}
}
I've had success adding the onAppear to the outermost view (in your case NavigationView):
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
}
}
}
.onAppear {
isFocused = true
}
}
// ...
I’m not certain but perhaps your onAppear attached to the TextField isn’t running. I would suggest adding a print inside of the onAppear to confirm the code is executing.
I faced the same problem and had the idea to solve it by embedding a UIViewController so could use viewDidAppear. Here is a working example:
import SwiftUI
import UIKit
struct FocusTestView : View {
#State var presented = false
var body: some View {
Button("Click Me") {
presented = true
}
.sheet(isPresented: $presented) {
LoginForm()
}
}
}
struct LoginForm : View {
enum Field: Hashable {
case usernameField
case passwordField
}
#State private var username = ""
#State private var password = ""
#FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .usernameField)
SecureField("Password", text: $password)
.focused($focusedField, equals: .passwordField)
Button("Sign In") {
if username.isEmpty {
focusedField = .usernameField
} else if password.isEmpty {
focusedField = .passwordField
} else {
// handleLogin(username, password)
}
}
}
.uiKitOnAppear {
focusedField = .usernameField
// If your form appears multiple times you might want to check other values before setting the focus.
}
}
}
struct UIKitAppear: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIAppearViewController {
let vc = UIAppearViewController()
vc.action = action
return vc
}
func updateUIViewController(_ controller: UIAppearViewController, context: Context) {
}
}
class UIAppearViewController: UIViewController {
var action: () -> Void = {}
override func viewDidLoad() {
view.addSubview(UILabel())
}
override func viewDidAppear(_ animated: Bool) {
// had to delay the action to make it work.
DispatchQueue.main.asyncAfter(deadline:.now()) { [weak self] in
self?.action()
}
}
}
public extension View {
func uiKitOnAppear(_ perform: #escaping () -> Void) -> some View {
self.background(UIKitAppear(action: perform))
}
}
UIKitAppear was taken from this dev forum post, modified with dispatch async to call the action. LoginForm is from the docs on FocusState with the uiKitOnAppear modifier added to set the initial focus state.
It could perhaps be improved by using a first responder method of the VC rather than the didAppear, then perhaps the dispatch async could be avoided.

How can I avoid this SwiftUI + Combine Timer Publisher reference cycle / memory leak?

I have the following SwiftUI view which contains a subview that fades away after five seconds. The fade is triggered by receiving the result of a Combine TimePublisher, but changing the value of showRedView in the sink publisher's sink block is causing a memory leak.
import Combine
import SwiftUI
struct ContentView: View {
#State var showRedView = true
#State var subscriptions: Set<AnyCancellable> = []
var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onAppear {
fadeRedView()
}
}
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.sink { _ in
withAnimation {
showRedView = false
}
}
.store(in: &subscriptions)
}
}
I thought this was somehow managed behind the scenes with the AnyCancellable collection. I'm relatively new to SwiftUI and Combine, so sure I'm either messing something up here or not thinking about it correctly. What's the best way to avoid this leak?
Edit: Adding some pictures showing the leak.
Views should be thought of as describing the structure of the view, and how it reacts to data. They ought to be small, single-purpose, easy-to-init structures. They shouldn't hold instances with their own life-cycles (like keeping publisher subscriptions) - those belong to the view model.
class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Timer.publish(every: 2.0, on: .main, in: .default).autoconnect()
.prefix(1)
.map { _ in }
.eraseToAnyPublisher()
}
}
And use .onReceive to react to published events in the View:
struct ContentView: View {
#State var showRedView = true
#ObservedObject vm = ViewModel()
var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onReceive(self.vm.pub, perform: {
withAnimation {
self.showRedView = false
}
})
}
}
So, it seems that with the above arrangement, the TimerPublisher with prefix publisher chain is causing the leak. It's also not the right publisher to use for your use case.
The following achieves the same result, without the leak:
class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Just(())
.delay(for: .seconds(2.0), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
My guess is that you're leaking because you store an AnyCancellable in subscriptions and you never remove it.
The sink operator creates the AnyCancellable. Unless you store it somewhere, the subscription will be cancelled prematurely. But if we use the Subscribers.Sink subscriber directly, instead of using the sink operator, there will be no AnyCancellable for us to manage.
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.subscribe(Subscribers.Sink(
receiveCompletion: { _ in },
receiveValue: { _ in
withAnimation {
showRedView = false
}
}
))
}
But this is still overkill. You don't need Combine for this. You can schedule the event directly:
func fadeRedView() {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
withAnimation {
showRedView = false
}
}
}

SwiftUI : Dismiss modal from child view

I'm attempting to dismiss a modal after its intended action is completed, but I have no idea how this can be currently done in SwiftUI. This modal is triggered by a #State value change. Would it be possible to change this value by observing a notification of sorts?
Desired actions: Root -> Initial Modal -> Presents Children -> Dismiss modal from any child
Below is what I've tried
Error: Escaping closure captures mutating 'self' parameter
struct AContentView: View {
#State var pageSaveInProgress: Bool = false
init(pages: [Page] = []) {
// Observe change to notify of completed action
NotificationCenter.default.publisher(for: .didCompletePageSave).sink { (pageSaveInProgress) in
self.pageSaveInProgress = false
}
}
var body: some View {
VStack {
//ETC
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
ModalWithChildren test action
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave, object: nil)},
label: { Text("Close") })
You can receive messages through .onReceive(_:perform) which can be called on any view. It registers a sink and saves the cancellable inside the view which makes the subscriber live as long as the view itself does.
Through it you can initiate #State attribute changes since it starts from the view body. Otherwise you would have to use an ObservableObject to which change can be initiated from anywhere.
An example:
struct MyView : View {
#State private var currentStatusValue = "ok"
var body: some View {
Text("Current status: \(currentStatusValue)")
}
.onReceive(MyPublisher.currentStatusPublisher) { newStatus in
self.currentStatusValue = newStatus
}
}
A complete example
import SwiftUI
import Combine
extension Notification.Name {
static var didCompletePageSave: Notification.Name {
return Notification.Name("did complete page save")
}
}
struct OnReceiveView: View {
#State var pageSaveInProgress: Bool = true
var body: some View {
VStack {
Text("Usual")
.onReceive(NotificationCenter.default.publisher(for: .didCompletePageSave)) {_ in
self.pageSaveInProgress = false
}
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
struct ModalWithChildren: View {
#State var presentChildModals: Bool = false
var body: some View {
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave,
object: nil
)
}) { Text("Send message") }
}
}

Resources