I am having some trouble with memory management in SwiftUI and Combine.
For example, if I have a NavigationView and then navigate to a detail view with a TextField, and enter a value in the TextField and tap on the back button, next time I go to that view the TextField has the previously entered value.
I noticed that the view-model is still in memory after the detail view is dismissed, and that's probably why the TextField still holds a value.
In UIKit, when dismissing a ViewController, the view-model will be deallocated and then created again when the ViewController is presented. This seems to not be the case here.
I attach some minimum reproductible code for this issue.
import SwiftUI
import Combine
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: OtherView()) {
Text("Press Here")
}
}
}
}
struct OtherView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
TextField("Something", text: $viewModel.enteredText)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
print("Tap")
}) {
Text("Tapping")
}.disabled(!viewModel.isValid)
}
}
}
class ViewModel: ObservableObject {
@Published var enteredText = ""
var isValid = false
var cancellable: AnyCancellable?
init() {
cancellable = textValidatedPublisher.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
}
deinit {
cancellable?.cancel()
}
var textValidatedPublisher: AnyPublisher<Bool, Never> {
$enteredText.map {
$0.count > 1
}.eraseToAnyPublisher()
}
}
I also noticed that, if for example, I add another view, let's say SomeOtherView after OtherView, then each time I type in the TextField from OtherView, then the deinit from SomeOtherView's view-model is called. Can anyone please also explain why this happens?
Moreover, I noticed that if I to a change in ContetView and the view is reevaluated, then I will have two ViewModels in memory
It is due to cross-reference in ViewModel
, here is fixed variant
struct OtherView: View, Constructable {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
TextField("Something", text: $viewModel.enteredText)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
print("Tap")
}) {
Text("Tapping")
}.disabled(!viewModel.isValid)
}
.onDisappear {
self.viewModel.invalidate() // << here !!
}
}
}
class ViewModel: ObservableObject {
@Published var enteredText = ""
var isValid = false
var cancellable: AnyCancellable?
init() {
print("[>>] created")
cancellable = textValidatedPublisher.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
}
func invalidate() {
cancellable?.cancel()
cancellable = nil
print("[<<] invalidated")
}
deinit {
// cancellable?.cancel() // not here !!!
print("[x] done")
}
var textValidatedPublisher: AnyPublisher<Bool, Never> {
$enteredText.map {
$0.count > 1
}.eraseToAnyPublisher()
}
}
--
Update:
is there a way to instantiate OtherView when navigating?
Here is a solution (tested with Xcode 11.4 / iOS 13.4), but this is only half-a-deal, because once created it will be alive until navigation link revalidated (ie. on back it remains in memory until next navigate)
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination:
// create wrapper view with type of view which creation
// is deferred until navigation
DeferCreatingView(of: OtherView.self)) {
Text("Press Here")
}
}
}
}
protocol Constructable {
init()
}
struct DeferCreatingView<T: View & Constructable>: View {
var ViewType: T.Type
init(of type: T.Type) {
ViewType = type
}
var body: some View {
ViewType.init() // << create only here
}
}
struct OtherView: View, Constructable {
// .. not changed code from first part
}
Add navigation view style .navigationViewStyle(StackNavigationViewStyle())
to Navigation View. It will deinit the view model. See your modified code below.
import SwiftUI
import Combine
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: OtherView()) {
Text("Press Here")
}
}
.navigationViewStyle(StackNavigationViewStyle())
// Added navigation style here.
}
}
struct OtherView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
TextField("Something", text: $viewModel.enteredText)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
print("Tap")
}) {
Text("Tapping")
}.disabled(!viewModel.isValid)
}
}
}
class ViewModel: ObservableObject {
@Published var enteredText = ""
var isValid = false
var cancellable: AnyCancellable?
init() {
cancellable = textValidatedPublisher.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
}
deinit {
cancellable?.cancel()
}
var textValidatedPublisher: AnyPublisher<Bool, Never> {
$enteredText.map {
$0.count > 1
}.eraseToAnyPublisher()
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With