I've had some problems with SwiftUI's navigation API, so I'm experimenting with implementing my own. Parts of this are relatively easy: I create a class NavModel that is basically a stack. Depending on what's on the top of that stack, I can display different views.
But I can't see how to implement something like SwiftUI's .navigationBarItems(...). That view modifier seems to use something like the Preferences API to pass its argument View up the hierarchy to the containing navigation system. Eg:
VStack {
...
}.navigationBarItems(trailing: Button("Edit") { startEdit() })
Anything that goes through onPreferenceChange(...) has to be Equatable, so if I want to pass an AnyView? for the navigation bar items, I need to somehow may it Equatable, and I don't see how to do that.
Here's some sample code that shows a basic push and pop navigation. I'm wondering: how could I make the navBarItems(...) work? (The UI is ugly, but that's not important now.)
struct ContentView: View {
#StateObject var navModel: NavModel = .shared
var body: some View {
NavView(model: navModel) { node in
switch node {
case .root: rootView
case .foo: fooView
}
}
}
var rootView: some View {
VStack {
Text("This is the root")
Button {
navModel.push(.foo)
} label: {
Text("Push a view")
}
}
}
var fooView: some View {
VStack {
Text("Foo")
Button {
navModel.pop()
} label: {
Text("Pop nav stack")
}
}.navBarItems(trailing: Text("Test"))
}
}
struct NavView<Content: View>: View {
#ObservedObject var model: NavModel
let makeViews: (NavNode) -> Content
init(model: NavModel, #ViewBuilder makeViews: #escaping (NavNode) -> Content) {
self.model = model
self.makeViews = makeViews
}
#State var navItems: AnyView? = nil
var body: some View {
VStack {
let node = model.stack.last!
navBar
Divider()
makeViews(node)
.frame(maxHeight: .infinity)
// This doesn't compile
.onPreferenceChange(NavBarItemsPrefKey.self) { v in
navItems = v
}
}
}
var navBar: some View {
HStack {
if model.stack.count > 1 {
Button {
model.pop()
} label: { Text("Back") }
}
Spacer()
if let navItems = self.navItems {
navItems
}
}
}
}
enum NavNode {
case root
case foo
}
class NavModel: ObservableObject {
static let shared = NavModel()
#Published var stack: [NavNode]
init() {
stack = [.root]
}
func push(_ node: NavNode) { stack.append(node) }
func pop() {
if stack.count > 1 {
stack.removeLast()
}
}
}
struct NavBarItemsPrefKey: PreferenceKey {
typealias Value = AnyView?
static var defaultValue: Value = nil
static func reduce(value: inout Value, nextValue: () -> Value) {
let n = nextValue()
if n != nil { // ???
value = n
}
}
}
// Is this the right way? But then anything passed to navBarItems(...) would need
// to be Equatable. The common case - Buttons - are not.
struct AnyEquatableView: Equatable {
???
init<T>(_ ev: EquatableView<T>) {
???
}
static func == (lhs: AnyEquatableView, rhs: AnyEquatableView) -> Bool {
???
}
}
struct NavBarItemsModifier<T>: ViewModifier where T: View {
let trailing: T
func body(content: Content) -> some View {
content.preference(key: NavBarItemsPrefKey.self, value: AnyView(trailing))
}
}
extension View {
func navBarItems<T>(trailing: T) -> some View where T: View {
return self.modifier(NavBarItemsModifier(trailing: trailing))
}
}
I try to recreate the .toolbar modifier Apple uses for their NavigationView. I created an own implementation of a NavigationStackView but also want to use a .toolbar modifier.
I got something to work using environment objects and custom view modifiers, but when I don't apply the .toolbar modifier this won't work because no environment object is set.
Is there a better way to do this? How does Apple do this?
Example:
import Foundation
import SwiftUI
class ToolbarData: ObservableObject {
#Published var view: (() -> AnyView)? = nil
init(_ view: #escaping () -> AnyView) {
self.view = view
}
}
struct NavigationStackView<Content: View>: View {
#ViewBuilder var content: () -> Content
#EnvironmentObject var toolbar: ToolbarData
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
if (toolbar.view != nil) {
toolbar.view!()
}
}
Spacer()
content()
Spacer()
}
}
}
struct NavigationStackToolbar<ToolbarContent: View>: ViewModifier {
var toolbar: ToolbarContent
func body(content: Content) -> some View {
content
.environmentObject(ToolbarData({
AnyView(toolbar)
}))
}
}
extension NavigationStackView {
func toolbar<Content: View>(_ content: () -> Content) -> some View {
modifier(NavigationStackToolbar(toolbar: content()))
}
}
struct NavigationStackView_Previews: PreviewProvider {
static var previews: some View {
NavigationStackView {
Text("Test")
}
.toolbar {
Text("Toolbar")
}
}
}
Current solution:
import Foundation
import SwiftUI
private struct ToolbarEnvironmentKey: EnvironmentKey {
static let defaultValue: AnyView = AnyView(EmptyView())
}
extension EnvironmentValues {
var toolbar: AnyView {
get { self[ToolbarEnvironmentKey.self] }
set { self[ToolbarEnvironmentKey.self] = newValue }
}
}
struct NavigationStackView<Content: View>: View {
#ViewBuilder var content: () -> Content
#Environment(\.toolbar) var toolbar: AnyView
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
toolbar
}
Spacer()
content()
Spacer()
}
}
}
extension NavigationStackView {
func toolbar<Content: View>(_ content: () -> Content) -> some View {
self
.environment(\.toolbar, AnyView(content()))
}
}
struct NavigationStackView_Previews: PreviewProvider {
static var previews: some View {
NavigationStackView {
Text("Test")
}
.toolbar {
Text("Toolbar")
}
}
}
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
It seems that LazyVStack is only "lazy" when within a ScrollView or List?
Code below:
struct ContentView: View {
var body: some View {
makeBody()
}
private func makeBody() -> some View {
// ScrollView { // uncomment to see difference
ContainerView { //
LazyVStack {
ForEach(1...100, id: \.self) {
WrappedText(s: String($0))
}
}
}
}
struct WrappedText: View {
var s: String
var body: some View {
makeBody()
}
private func makeBody() -> some View {
print("Wrapped text body: \(s)") // this is called for all texts during initialisation (if not in a ScrollView / List)
return Text(String(s))
}
}
}
struct ContainerView<Content: View>: View {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
return self.content
}
}
I'm currently experimenting with creating a pure SwiftUI ScrollView, and am stuck on this.
I've tried using .clipped() and setting the .frame on ContainerView and LazyVStack. Any suggestions?
I've created a custom view which is basically a VStack with modifiers applied to it. But unlike the original VStack view, I have to use a grouping view when I'm using it with multiple subviews.
How can I get rid of the "Group" in the below example?
import SwiftUI
struct ContentView: View {
var body: some View {
CustomGroup() {
Group {
Text("Hello")
Text("World")
}
}
}
}
struct CustomGroup<Content>: View where Content : View {
let content: () -> Content
var body: some View {
VStack() {
content()
}
.background(Color.yellow)
.cornerRadius(8)
}
}
You need init with ViewBuilder
Here is a solution. Tested with Xcode 12 / iOS 14
struct TestCustomGroup: View {
var body: some View {
CustomGroup {
Text("Hello")
Text("World")
}
}
}
struct CustomGroup<Content>: View where Content : View {
let content: () -> Content
init(#ViewBuilder _ content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
content()
}
.background(Color.yellow)
.cornerRadius(8)
}
}