SwiftUI 4.0 introduces a new NavigationStack view.
Let's consider this simple structure.
struct Item: Identifiable, Hashable {
static let sample = [Item(), Item(), Item()]
let id = UUID()
}
When a NavigationLink is nested in another one, the navigation loses its animation and the backtracking takes directly to the root. Did I miss something, or is this a bug?
struct ItemDetailView: View {
let item: Item
var body: some View {
Text(item.id.uuidString)
}
}
struct ItemListView: View {
var body: some View {
List(Item.sample) { item in
NavigationLink(item.id.uuidString, value: item)
}
}
}
struct ExploreView: View {
var body: some View {
List {
Section {
NavigationLink {
ItemListView()
} label: {
Text("Items")
}
}
}
.navigationTitle("Explore")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
ExploreView()
}
}
}
Thanks!
Found the solution thanks to #Asperi's comment.
First, create a Hashable enum containing the destinations.
enum Destination: Hashable {
case items
var view: some View {
switch self {
case .items:
return ItemListView()
}
}
var title: LocalizedStringKey {
switch self {
case .items:
return "Items"
}
}
}
Next, use the new NavigationLink initializer.
NavigationLink(Destination.items.title, value: Destination.items)
And finally, add a new .navigationDestination modifier to catch all Destination values.
.navigationDestination(for: Destination.self) { destination in
destination.view
}
I have an AsyncContentView that handles the loading of data when the view appears and handles the switching of a loading view and the content (Taken from here swiftbysundell):
struct AsyncContentView<P:Parsable, Source:Loader<P>, Content: View>: View {
#ObservedObject private var source: Source
private var content: (P.ReturnType) -> Content
init?(source: Source, reloadAfter reloadTime:UInt64 = 0, #ViewBuilder content: #escaping (P.ReturnType) -> Content) {
self.source = source
self.content = content
}
func loadInfo() {
Task {
await source.loadData()
}
}
var body: some View {
switch source.state {
case .idle:
return AnyView(Color.clear.onAppear(perform: loadInfo))
case .loading:
return AnyView(ProgressView("Loading..."))
case .loaded(let output):
return AnyView(content(output))
}
}
}
For completeness, here's the Parsable protocol:
protocol Parsable: ObservableObject {
associatedtype ReturnType
init()
var result: ReturnType { get }
}
And the LoadingState and Loader
enum LoadingState<Value> {
case idle
case loading
case loaded(Value)
}
#MainActor
class Loader<P:Parsable>: ObservableObject {
#Published public var state: LoadingState<P.ReturnType> = .idle
func loadData() async {
self.state = .loading
await Task.sleep(2_000_000_000)
self.state = .loaded(P().result)
}
}
Here is some dummy data I am using:
struct Interface: Hashable {
let name:String
}
struct Interfaces {
let interfaces: [Interface] = [
Interface(name: "test1"),
Interface(name: "test2"),
Interface(name: "test3")
]
var selectedInterface: Interface { interfaces.randomElement()! }
}
Now I put it all together like this which does it's job. It processes the async function which shows the loading view for 2 seconds, then produces the content view using the supplied data:
struct ContentView: View {
class SomeParsableData: Parsable {
typealias ReturnType = Interfaces
required init() { }
var result = Interfaces()
}
#StateObject var pageLoader: Loader<SomeParsableData> = Loader()
#State private var selectedInterface: Interface?
var body: some View {
AsyncContentView(source: pageLoader) { result in
Picker(selection: $selectedInterface, label: Text("Selected radio")) {
ForEach(result.interfaces, id: \.self) {
Text($0.name)
}
}
.pickerStyle(.segmented)
}
}
}
Now the problem I am having, is this data contains which segment should be selected. In my real app, this is a web request to fetch data that includes which segment is selected.
So how can I have this view update the selectedInterface #state property?
If I simply add the line
self.selectedInterface = result.selectedInterface
into my AsyncContentView I get this error
Type '()' cannot conform to 'View'
You can do it in onAppear of generated content, but I suppose it is better to do it not directly but via binding (which is like a reference to state's external storage), like
var body: some View {
let selected = self.$selectedInterface
AsyncContentView(source: pageLoader) { result in
Picker(selection: selected, label: Text("Selected radio")) {
ForEach(result.interfaces, id: \.self) {
Text($0.name).tag(Optional($0)) // << here !!
}
}
.pickerStyle(.segmented)
.onAppear {
selected.wrappedValue = result.selectedInterface // << here !!
}
}
}
I've got a very simple app example that has two views: a MasterView and a DetailView. The MasterView is presented inside a ContentView with a NavigationView:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
MasterView(viewModel: MasterViewModel())
.navigationBarTitle(Text("Master"))
.navigationBarItems(
leading: EditButton()
)
}
}
}
struct MasterView: View {
#ObservedObject private var viewModel: MasterViewModel
init(viewModel: MasterViewModel) {
self.viewModel = viewModel
}
var body: some View {
print("Test")
return DataStatusView(dataSource: self.$viewModel.result) { texts -> AnyView in
print("Closure")
return AnyView(List {
ForEach(texts, id: \.self) { text in
NavigationLink(
destination: DetailView(viewModel: DetailViewModel(stringToDisplay: text))
) {
Text(text)
}
}
})
}.onAppear {
if case .waiting = self.viewModel.result {
self.viewModel.fetch()
}
}
}
}
struct DetailView: View {
#ObservedObject private var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
self.showView().onAppear {
self.viewModel.fetch()
}
.navigationBarTitle(Text("Detail"))
}
func showView() -> some View {
switch self.viewModel.result {
case .found(let s):
return AnyView(Text(s))
default:
return AnyView(Color.red)
}
}
}
The DataStatusView is a simple view to manage some state:
public enum ResultState<T, E: Error> {
case waiting
case loading
case found(T)
case failed(E)
}
struct DataStatusView<Content, T>: View where Content: View {
#Binding private(set) var dataSource: ResultState<T, Error>
private let content: (T) -> Content
private let waitingContent: AnyView?
#inlinable init(dataSource: Binding<ResultState<T, Error>>,
waitingContent: AnyView? = nil,
#ViewBuilder content: #escaping (T) -> Content) {
self._dataSource = dataSource
self.waitingContent = waitingContent
self.content = content
}
var body: some View {
self.buildMainView()
}
private func buildMainView() -> some View {
switch self.dataSource {
case .waiting:
return AnyView(Color.red)
case .loading:
return AnyView(Color.green)
case .found(let data):
return AnyView(self.content(data))
case .failed:
return AnyView(Color.yellow)
}
}
}
and the view models are a very simple "pretend to make a network call" vm:
final class MasterViewModel: ObservableObject {
#Published var result: ResultState<[String], Error> = .waiting
init() { }
func fetch() {
self.result = .loading
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.result = .found(["This", "is", "a", "test"])
}
}
}
final class DetailViewModel: ObservableObject {
#Published var result: ResultState<String, Error> = .waiting
private let stringToDisplay: String
init(stringToDisplay: String) {
self.stringToDisplay = stringToDisplay
}
func fetch() {
self.result = .loading
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.result = .found(self.stringToDisplay)
}
}
}
Now the problem I'm having is that every time I go from Master -> Detail view the block inside the DataStatusView is called. This is a problem because the "DetailView" is constantly re-created (and therefore its vm too, which causes the loading of the detail's data to fail).
This is happening because when I go from master -> detail the buttons in the navigation bar change (or at least that's the hypothesis). When I remove the lines:
.navigationBarItems(
leading: EditButton()
)
This works as "expected".
What is the "SwiftUI" way of dealing with this? A sample project that shows this issue is here: https://github.com/kerrmarin/swiftui-mvvm-master-detail
I was wondering how to provide an empty state view in a list when the data source of the list is empty. Below is an example, where I have to wrap it in an if/else statement. Is there a better alternative for this, or is there a way to create a modifier on a List that'll make this possible i.e. List.emptyView(Text("No data available...")).
import SwiftUI
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
VStack {
if objects.isEmpty {
Text("Oops, loos like there's no data...")
} else {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
}
}
}
}
struct EmptyListExample_Previews: PreviewProvider {
static var previews: some View {
EmptyListExample(objects: [])
}
}
I quite like to use an overlay attached to the List for this because it's quite a simple, flexible modifier:
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
VStack {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
.overlay(Group {
if objects.isEmpty {
Text("Oops, loos like there's no data...")
}
})
}
}
}
It has the advantage of being nicely centred & if you use larger placeholders with an image, etc. they will fill the same area as the list.
One of the solutions is to use a #ViewBuilder:
struct EmptyListExample: View {
var objects: [Int]
var body: some View {
listView
}
#ViewBuilder
var listView: some View {
if objects.isEmpty {
emptyListView
} else {
objectsListView
}
}
var emptyListView: some View {
Text("Oops, loos like there's no data...")
}
var objectsListView: some View {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
}
}
You can create a custom modifier that substitutes a placeholder view when your list is empty. Use it like this:
List(items) { item in
Text(item.name)
}
.emptyPlaceholder(items) {
Image(systemName: "nosign")
}
This is the modifier:
struct EmptyPlaceholderModifier<Items: Collection>: ViewModifier {
let items: Items
let placeholder: AnyView
#ViewBuilder func body(content: Content) -> some View {
if !items.isEmpty {
content
} else {
placeholder
}
}
}
extension View {
func emptyPlaceholder<Items: Collection, PlaceholderView: View>(_ items: Items, _ placeholder: #escaping () -> PlaceholderView) -> some View {
modifier(EmptyPlaceholderModifier(items: items, placeholder: AnyView(placeholder())))
}
}
I tried #pawello2222's approach, but the view didn't get rerendered if the passed objects' content change from empty(0) to not empty(>0), or vice versa, but it worked if the objects' content was always not empty.
Below is my approach to work all the time:
struct SampleList: View {
var objects: [IdentifiableObject]
var body: some View {
ZStack {
Empty() // Show when empty
List {
ForEach(objects) { object in
// Do something about object
}
}
.opacity(objects.isEmpty ? 0.0 : 1.0)
}
}
}
You can make ViewModifier like this for showing the empty view. Also, use View extension for easy use.
Here is the demo code,
//MARK: View Modifier
struct EmptyDataView: ViewModifier {
let condition: Bool
let message: String
func body(content: Content) -> some View {
valideView(content: content)
}
#ViewBuilder
private func valideView(content: Content) -> some View {
if condition {
VStack{
Spacer()
Text(message)
.font(.title)
.foregroundColor(Color.gray)
.multilineTextAlignment(.center)
Spacer()
}
} else {
content
}
}
}
//MARK: View Extension
extension View {
func onEmpty(for condition: Bool, with message: String) -> some View {
self.modifier(EmptyDataView(condition: condition, message: message))
}
}
Example (How to use)
struct EmptyListExample: View {
#State var objects: [Int] = []
var body: some View {
NavigationView {
List(objects, id: \.self) { obj in
Text("\(obj)")
}
.onEmpty(for: objects.isEmpty, with: "Oops, loos like there's no data...") //<--- Here
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button("Add") {
objects = [1,2,3,4,5,6,7,8,9,10]
}
Button("Empty") {
objects = []
}
}
}
}
}
}
In 2021 Apple did not provide a List placeholder out of the box.
In my opinion, one of the best way to make a placeholder, it's creating a custom ViewModifier.
struct EmptyDataModifier<Placeholder: View>: ViewModifier {
let items: [Any]
let placeholder: Placeholder
#ViewBuilder
func body(content: Content) -> some View {
if !items.isEmpty {
content
} else {
placeholder
}
}
}
struct ContentView: View {
#State var countries: [String] = [] // Data source
var body: some View {
List(countries) { country in
Text(country)
.font(.title)
}
.modifier(EmptyDataModifier(
items: countries,
placeholder: Text("No Countries").font(.title)) // Placeholder. Can set Any SwiftUI View
)
}
}
Also via extension can little bit improve the solution:
extension List {
func emptyListPlaceholder(_ items: [Any], _ placeholder: AnyView) -> some View {
modifier(EmptyDataModifier(items: items, placeholder: placeholder))
}
}
struct ContentView: View {
#State var countries: [String] = [] // Data source
var body: some View {
List(countries) { country in
Text(country)
.font(.title)
}
.emptyListPlaceholder(
countries,
AnyView(ListPlaceholderView()) // Placeholder
)
}
}
If you are interested in other ways you can read the article
I have already read this thread
SwiftUI - Button - How to pass a function (with parameters) request to parent from child
however after the original poster edited his own answer he proposed a way that didn't match his own question.
Unfortunately I have not yet reached enough points to post comments in this thread
This is the code example from the post above repeated to explain the problem:
struct ChildView: View {
var function: () -> Void
var body: some View {
Button(action: {
self.function()
}, label: {
Text("Button")
})
}
}
struct ContentView: View {
var body: some View {
ChildView(function: { self.setViewBackToNil() })
}
func setViewBackToNil() {
print("I am the parent")
}
}
And now I want to add a String parameter to setViewBackToNil(myStringParameter: String)
Here is possible solution. Tested with Xcode 11.4 / iOS 13.4
struct ChildView: View {
var function: (String) -> Void
#State private var value = "Child Value"
var body: some View {
Button(action: {
self.function(self.value)
}, label: {
Text("Button")
})
}
}
struct ContentView: View {
var body: some View {
ChildView { self.setViewBackToNil(myStringParameter: $0) }
}
func setViewBackToNil(myStringParameter: String) {
print("I am the parent: \(myStringParameter)")
}
}