SwiftUI - multiple levels deep NavigationLink does not work - swift

So I thought I found a way to make navigation in SwiftUI flexible and loosely coupled, yet still state-based and somewhat free of imperative-navigation bugs (double push, etc).
Basic idea is to have a linked list of Views (erased to AnyView) and a recursive view with NavigationLink in it, which is active when corresponding view is present in the list
But it does not work and I don't understand why. On iOS device it only pushes one level deep, even though the list is multiple levels deep and the bindings return true
Is it a SwiftUI bug or am I missing something?
struct ContentView: View {
#State
var navigationList: NavigationList?
var body: some View {
NavigationView {
Navigatable(list: $navigationList) {
Button("Push test", action: {
navigationList = .init(next: nil, screen: Screen {
TestView()
})
})
}
}
}
}
struct TestView: View {
#Environment(\.navigationList)
#Binding
var list
var body: some View {
Button("Push me", action: {
list = .init(next: nil, screen: Screen {
TestView()
})
})
}
}
struct Navigatable<Content: View>: View {
#Binding
var list: NavigationList?
let content: () -> Content
init(list: Binding<NavigationList?>, #ViewBuilder content: #escaping () -> Content) {
self._list = list
self.content = content
}
var body: some View {
ZStack {
NavigationLink(
isActive: isActive,
destination: {
Navigatable<Screen?>(list: childBinding) {
list?.screen
}
},
label: EmptyView.init
).hidden()
LazyView {
content()
}.environment(\.navigationList, $list)
}
}
var isActive: Binding<Bool> {
.init(
get: { list != nil },
set: {
if !$0 {
list = nil
}
}
)
}
var childBinding: Binding<NavigationList?> {
.init(
get: { list?.next },
set: { list?.next = $0 }
)
}
}
struct Screen: View {
let content: () -> AnyView
init<C: View>(#ViewBuilder content: #escaping () -> C) {
self.content = {
.init(content())
}
}
var body: some View {
content()
}
}
struct NavigationList {
#Indirect
var next: NavigationList?
let screen: Screen
}
enum NavigationListKey: EnvironmentKey {
static var defaultValue: Binding<NavigationList?> {
.constant(nil)
}
}
extension EnvironmentValues {
var navigationList: Binding<NavigationList?> {
get { self[NavigationListKey.self] }
set { self[NavigationListKey.self] = newValue }
}
}
struct LazyView<Content: View>: View {
#ViewBuilder var content: () -> Content
var body: some View {
content()
}
}
#propertyWrapper
struct Indirect<Wrapped> {
private final class Storage: CustomReflectable {
var wrapped: Wrapped
init(_ wrapped: Wrapped) {
self.wrapped = wrapped
}
var customMirror: Mirror {
.init(self, children: [(label: "wrapped", value: wrapped)])
}
}
private let storage: Storage
var wrappedValue: Wrapped {
get { storage.wrapped }
mutating set { storage.wrapped = newValue }
}
init(wrappedValue: Wrapped) {
self.storage = .init(wrappedValue)
}
}

You’re missing isDetailLink(false) which is what allows multiple screens to be pushed on to one navigation controller.
But there are also structural problems with the code. It's best to use SwiftUI View data structs as designed and let them store the hierachy of data. If you go off on your own architecture then you lose the magic like invalidation and diffing and it'll likely slow down too.

Related

Nesting of several NavigationLink in a NavigationStack causes the loss of animation and breaks the backtracking

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
}

Updating a #State property from within a SwiftUI 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 !!
}
}
}

SwiftUI #Binding reloading on push/pop with different navigation items

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

SwiftUI list empty state view/modifier

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

SwiftUI How to pass data from child to parent as done in C# with the 'Delegate-EventHandler-EventArgs' way

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)")
}
}