I'm having an issue with SwiftUI with a presented .sheet
when a List
content change. Let me explain in detail the situation.
I provided a sample example on Github that you can clone so you can easily reproduce the bug.
I have only 2 views :
PlaylistCreationView
SearchView
Each of them have respectively their view models called PlaylistCreationViewModel
and SearchViewModel
.
The PlaylistCreationView
has a Add Songs
button witch will present the SearchView
has a sheet
. When the user touch a song in the search results, the SearchView
sends a onAddSong
completion handler that will notify the PlaylistCreationView
that a song has been added.
Here a simple schema of the situation:
Here is also some screenshots of the views:
When the user touch a song in the SearchView
, the SearchView
content is redrawn. I mean the view is recreated. The view appears has if it have been loaded for the first time again. Just like this:
To simplify the problem, here is another schema of the situation:
Here is what I can observe just after touching a song:
As you can see, the song has been added to the playlist correctly, but the SearchView
is now blank because it has been recreated.
What I can't explain, is why the change of a list in the PlaylistCreationViewModel
leads to redrawing the presented sheet
.
I provided a sample example on Github that you can clone. To reproduce the issue:
final class PlaylistCreationViewModel: ObservableObject {
@Published var sheetType: SheetType?
@Published var songs: [String] = []
}
extension PlaylistCreationViewModel {
enum SheetType: String, Identifiable {
case search
var id: String {
rawValue
}
}
}
struct PlaylistCreationView: View {
@ObservedObject var viewModel: PlaylistCreationViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.songs, id: \.self) { song in
Text(song)
}
Button("Add Song") {
viewModel.sheetType = .search
}
.foregroundColor(.accentColor)
}
.navigationTitle("Playlist")
}
.sheet(item: $viewModel.sheetType) { _ in
sheetView
}
}
private var sheetView: some View {
let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
// This line leads SwiftUI to rebuild the SearchView
viewModel.songs.append(addedSong)
}
let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
return NavigationView {
SearchView(viewModel: sheetViewModel)
.navigationTitle("Search")
}
}
}
final class SearchViewModel: ObservableObject {
@Published var search: String = ""
let songs: [String] = ["Within", "One more time", "Veridis quo"]
let addingMode: AddingMode
init(addingMode: AddingMode) {
self.addingMode = addingMode
}
}
extension SearchViewModel {
enum AddingMode {
case playlist(onAddSong: (_ song: String) -> Void)
}
}
struct SearchView: View {
@ObservedObject var viewModel: SearchViewModel
var body: some View {
VStack {
TextField("Search", text: $viewModel.search)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
List(viewModel.songs, id: \.self) { song in
Button(song) {
switch viewModel.addingMode {
case .playlist(onAddSong: let onAddSong):
onAddSong(song)
}
}
}
}
}
}
I'm pretty sure it is some SwiftUI stuff that I'm missing. But I can't get it. I didn't encountered such a case before. I couldn't find anything on the web.
Any help or advice would be really appreciated. Thanks in advance to anyone who'll take the time to read and understand the problem. I hope I've described the problem well enough.
The Problem
When you tap on Songs in SearchView
you are invoking a closure
block, that is defined inside Sheet
view. The code below-:
private var sheetView: some View {
let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
// This line leads SwiftUI to rebuild the SearchView
viewModel.songs.append(addedSong)
}
let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
return NavigationView {
SearchView(viewModel: sheetViewModel)
.navigationTitle("Search")
}
}
The goal of this closure is to add selected song in Songs
array, present in PlaylistCreationViewModel
class. Below is that class-:
final class PlaylistCreationViewModel: ObservableObject {
@Published var sheetType: SheetType?
@Published var songs: [String] = []
}
As you can see this class is ObservableObject
, and your songs
array is @Published
property, as soon as you add a song to this array, any view that is using this model and marked with @ObservedObject
will refresh its body .In your case that view is below-:
struct PlaylistCreationView: View {
@ObservedObject var viewModel: PlaylistCreationViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.songs, id: \.self) { song in
Text(song)
}
Button("Add Song") {
viewModel.sheetType = .search
}
.foregroundColor(.accentColor)
}
.navigationTitle("Playlist")
}
.sheet(item: $viewModel.sheetType) { _ in
sheetView
}
}
private var sheetView: some View {
let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
// This line leads SwiftUI to rebuild the SearchView
viewModel.songs.append(addedSong)
}
let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
return NavigationView {
SearchView(viewModel: sheetViewModel)
.navigationTitle("Search")
}
}
Now when this view gets refreshed after adding a song it will also reload sheetView
view, why? because your sheet is reading its state from viewModel.sheetType
whose values is still set to .search
Now, when sheet is called, it also create SearchViewModel
objects again, and give it to SearchView
, So you are getting complete new Object with empty search string. Below is that model class-:
final class SearchViewModel: ObservableObject {
@Published var search: String = ""
let songs: [String] = ["Within", "One more time", "Veridis quo"]
let addingMode: AddingMode
init(addingMode: AddingMode) {
self.addingMode = addingMode
}
}
Solution-:
You can move addingMode
and sheetViewModel
property outside of sheetView
, and put them inside PlaylistCreationView
just above body property. This way when sheet is refreshed your object wouldn’t be instantiated from scratch every time.
Working Code-:
mport SwiftUI
struct PlaylistCreationView: View {
@ObservedObject var viewModel: PlaylistCreationViewModel
var sheetViewModel: SearchViewModel
init(viewModel:PlaylistCreationViewModel) {
_viewModel = ObservedObject(wrappedValue: viewModel)
sheetViewModel = SearchViewModel(addingMode: .playlist(onAddSong: { addSong in
viewModel.songs.append(addSong)
}))
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.songs, id: \.self) { song in
Text(song)
}
Button("Add Song") {
viewModel.sheetType = .search
}
.foregroundColor(.accentColor)
}
.navigationTitle("Playlist")
}
.sheet(item: $viewModel.sheetType) { _ in
sheetView
}
}
private var sheetView: some View {
NavigationView {
SearchView(model: sheetViewModel)
.navigationTitle("Search")
}
}
}
SearchView-:
import SwiftUI
struct SearchView: View {
@ObservedObject var viewModel: SearchViewModel
init(model:SearchViewModel) {
_viewModel = ObservedObject(wrappedValue: model)
}
var body: some View {
VStack {
TextField("Search", text: $viewModel.search)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
List(viewModel.songs, id: \.self) { song in
Button(song) {
switch viewModel.addingMode {
case .playlist(onAddSong: let onAddSong):
onAddSong(song)
}
}
}
}
}
}