SwiftUI - Pages with embedded NavigationViews - swiftui

I'm attempting to build a paging app, where each page has its own NavigationView. It sort of works, but I get a completely redundant "Back" button in the navigation bar. My code so far is -
struct AllListsView: View {
#ObservedObject var viewModel: AllListsViewModel
#State private var pageName: String = ""
init(viewModel: AllListsViewModel) {
self.viewModel = viewModel
}
var body: some View {
IfLet($viewModel.listViews) { listViews in
TabView {
ForEach(listViews) { $0 }
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
} else: {
Text("No lists")
}
}
}
struct ListView: View, Identifiable, Hashable {
let id: String
private var viewModel: ListViewModel
init(viewModel: ListViewModel) {
self.id = String(viewModel.list.index)
self.viewModel = viewModel
}
var body: some View {
NavigationView {
Text("Any Text") // if I don't have this, nothing is displayed
List(viewModel.listItems) {
Text("\($0.quantity) \($0.name)")
}
.navigationBarTitle(viewModel.list.name)
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ListView, rhs: ListView) -> Bool {
lhs.id == rhs.id
}
}
However, if I put the NavigationView outside the TabView, it displays correctly, but then scrolling the list doesn't shrink the large title text and I have to update the navigationBar title when the page changes -
// AllListsView -
var body: some View {
IfLet($viewModel.listViews) { listViews in
NavigationView {
TabView {
ForEach(listViews, id: \.self) { listView in
listView
.onAppear {
pageName = listView.name
}
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.navigationBarTitle(pageName)
}
} else: {
Text("No lists")
}
}
// ListView -
var body: some View {
List(viewModel.listItems) {
Text("\($0.quantity) \($0.name)")
}
}
I'd like to be able to do this in SwiftUI, as it looks like the way ahead. But I know that for me to do this using UIKit would be fairly trivial, so it's frustrating.
Any help much appreciated!

Related

How to dismiss a Sheet and open a NavigationLink in a new View?

I have a View with a search button in the toolbar. The search button presents a sheet to the user and when he clicks on a result I would like the sheet to be dismissed and a detailView to be opened rather than navigating to the detailView from inside the sheet. The dismiss part is easy, but how do I open the detailView in the NavigationStack relative to the original View that presented the Sheet?
I'm also getting an error on the navigationStack initialization.
HomeScreen:
struct CatCategoriesView: View {
#StateObject private var vm = CatCategoriesViewModel(service: Webservice())
#State var showingSearchView = false
#State var path: [CatDetailView] = []
var body: some View {
NavigationStack(path: $path) { <<-- Error here "No exact matches in call to initializer "
ZStack {
Theme.backgroundColor
.ignoresSafeArea()
ScrollView {
switch vm.state {
case .success(let cats):
LazyVStack {
ForEach(cats, id: \.id) { cat in
NavigationLink {
CatDetailView(cat: cat)
} label: {
CatCategoryCardView(cat: cat)
.padding()
}
}
}
case .loading:
ProgressView()
default:
EmptyView()
}
}
}
.navigationTitle("CatPedia")
.toolbar {
Button {
showingSearchView = true
} label: {
Label("Search", systemImage: "magnifyingglass")
}
}
}
.task {
await vm.getCatCategories()
}
.alert("Error", isPresented: $vm.hasError, presenting: vm.state) { detail in
Button("Retry") {
Task {
await vm.getCatCategories()
}
}
} message: { detail in
if case let .failed(error) = detail {
Text(error.localizedDescription)
}
}
.sheet(isPresented: $showingSearchView) {
SearchView(vm: vm, path: $path)
}
}
}
SearchView:
struct SearchView: View {
let vm: CatCategoriesViewModel
#Environment(\.dismiss) private var dismiss
#Binding var path: [CatDetailView]
#State private var searchText = ""
var body: some View {
NavigationStack {
List {
ForEach(vm.filteredCats, id: \.id) { cat in
Button(cat.name) {
dismiss()
path.append(CatDetailView(cat: cat))
}
}
}
.navigationTitle("Search")
.searchable(text: $searchText, prompt: "Find a cat..")
.onChange(of: searchText, perform: vm.search)
}
}
}
It can be a little tricky, but I'd suggest using a combination of Apple's documentation on "Control a presentation link programmatically" and shared state. To achieve the shared state, I passed a shared view model into the sheet.
I have simplified your example to get it working in a more generic way. Hope this will work for you!
ExampleParentView.swift
import SwiftUI
struct ExampleParentView: View {
#StateObject var viewModel = ExampleViewModel()
var body: some View {
NavigationStack(path: $viewModel.targetDestination) {
List {
NavigationLink("Destination A", value: TargetDestination.DestinationA)
NavigationLink("Destination B", value: TargetDestination.DestinationB)
}
.navigationDestination(for: TargetDestination.self) { target in
switch target {
case .DestinationA:
DestinationA()
case .DestinationB:
DestinationB()
}
}
.navigationTitle("Destinations")
Button(action: {
viewModel.showModal = true
}) {
Text("Click to open sheet")
}
}
.sheet(isPresented: $viewModel.showModal, content: {
ExampleSheetView(viewModel: viewModel)
.interactiveDismissDisabled()
})
}
}
ExampleViewModel.swift
import Foundation
import SwiftUI
class ExampleViewModel: ObservableObject {
#Published var showModal = false
#Published var targetDestination: [TargetDestination] = []
}
enum TargetDestination {
case DestinationA
case DestinationB
}
ExampleSheetView.swift
import SwiftUI
struct ExampleSheetView: View {
let viewModel: ExampleViewModel
var body: some View {
VStack {
Text("I am the sheet")
Button(action: {
viewModel.showModal = false
viewModel.targetDestination.append(.DestinationA)
}) {
Text("Close the sheet and navigate to `A`")
}
Button(action: {
viewModel.showModal = false
viewModel.targetDestination.append(.DestinationB)
}) {
Text("Close the sheet and navigate to `B`")
}
}
}
}
DestinationA.swift
import SwiftUI
struct DestinationA: View {
var body: some View {
Text("Destination A")
}
}
DestinationB.swift
import SwiftUI
struct DestinationB: View {
var body: some View {
Text("Destination B")
}
}

Default navigation link in Swiftui

I'm working on an app the uses traditional sidebar navigation with a detail view. I've synthesized the app to illustrate two issues.
when the app starts, the detail view is empty. How can I programmatically select an entry in the sidebar to show in the detail view?
The sidebar allows swipe to delete. If the selected row (the one showing in the detail view) is deleted, it still shows in the detail view. How can update the detail view with, for example, an empty view?
Here's the source code for the app illustrating the issues:
import SwiftUI
class Model: ObservableObject {
var items = [Item("")]
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
class Item: Identifiable, Hashable {
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
let id = UUID()
#Published var name: String
init(_ name: String) {
self.name = name
}
}
#main
struct IBTSimulatorApp: App {
#StateObject var model = Model.loadData
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach($model.items, id: \.self) { $item in
NavigationLink(item.name, destination: Text(item.name))
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
private func addItem() {
withAnimation {
model.items.append(Item("New item (\(model.items.count))"))
model.objectWillChange.send()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
model.items.remove(atOffsets: offsets)
model.objectWillChange.send()
}
}
}
For 1. you can use the NavigationLink version with tag and selection, and save the active selection in a persisted AppStoragevar.
For 2. I expected you can set the selection to nil, but that does not work for some reason. But you can set it to the first item in the sidebar list.
As a general note you should make Item a struct instead of a class. Only the published Model should be a class.
class Model: ObservableObject {
var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
struct Item: Identifiable { // Change from class to struct!
let id = UUID()
var name: String
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
#AppStorage("selectemItem") var selected: String? // bind to persisted var here
var body: some View {
NavigationView {
List {
ForEach(model.items) { item in //no .id needed as Item is identifiable
NavigationLink(tag: item.id.uuidString, selection: $selected) { // use link with selection here
Text(item.name)
} label: {
Text(item.name)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Nothing selected")
}
}
private func addItem() {
withAnimation {
model.objectWillChange.send()
model.items.append(Item("New item (\(model.items.count))"))
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
// model.objectWillChange.send() // not necessary if Item is struct
self.selected = nil // for some reaseon this does not work
self.selected = model.items.first?.id.uuidString // selects first item
model.items.remove(atOffsets: offsets)
}
}
}

How to show in SwiftUI the sidebar in iPad and portrait mode

I have an master detail app in iPad, and when run the app in portrait mode the sidebar is hidden. I need to push Back button to open the sidebar.
Can anyone help me to show the sidebar by default?
I found an answer that suggest to use StackNavigationViewStyle when the app is in portrait, but then the app seems like a giant iPhone and dissapears the master class like a sidebar to appear like a view.
Thats my code.
struct ContentView: View {
var body: some View {
NavigationView {
MyMasterView()
DetailsView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct MyMasterView: View {
var people = ["Option 1", "Option 2", "Option 3"]
var body: some View {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: DetailsView()) {
Text(person)
}
}
}
}
}
struct DetailsView: View {
var body: some View {
Text("Hello world")
.font(.largeTitle)
}
}
Thank you
It can be done, but for now it requires access to UIKit's UISplitViewController via UIViewRepresentable. Here's an example, based on a solution described here.
import SwiftUI
import UIKit
struct UIKitShowSidebar: UIViewRepresentable {
let showSidebar: Bool
func makeUIView(context: Context) -> some UIView {
let uiView = UIView()
if self.showSidebar {
DispatchQueue.main.async { [weak uiView] in
uiView?.next(of: UISplitViewController.self)?
.show(.primary)
}
} else {
DispatchQueue.main.async { [weak uiView] in
uiView?.next(of: UISplitViewController.self)?
.show(.secondary)
}
}
return uiView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
DispatchQueue.main.async { [weak uiView] in
uiView?.next(
of: UISplitViewController.self)?
.show(showSidebar ? .primary : .secondary)
}
}
}
extension UIResponder {
func next<T>(of type: T.Type) -> T? {
guard let nextValue = self.next else {
return nil
}
guard let result = nextValue as? T else {
return nextValue.next(of: type.self)
}
return result
}
}
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("Primary view (a.k.a. Sidebar)", destination: DetailView())
}
NothingView()
}
}
}
struct DetailView: View {
var body: some View {
Text("Secondary view (a.k.a Detail)")
}
}
struct NothingView: View {
#State var showSidebar: Bool = false
var body: some View {
Text("Nothing to see")
if UIDevice.current.userInterfaceIdiom == .pad {
UIKitShowSidebar(showSidebar: showSidebar)
.frame(width: 0,height: 0)
.onAppear {
showSidebar = true
}
.onDisappear {
showSidebar = false
}
}
}
}
In iOS 16 you'll be able to control it using columnVisibility

Refreshing a SwiftUI List

Ím trying to refresh this List whenever I click on a NavLink
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: ListFeedItemDetail(idx: i).environmentObject(self.feed)) {
ListFeedItem(item: self.$feed.items[i])
}
}
}
The list is made out of an array inside an environment object.
The problem: It does only refresh when I switch to another tab or close the app
I had used a modal View before and it worked there. (I did it with .onAppear)
Any Ideas?
Example
Problem: When you tap on an item in the list and tap the toggle button the EnvironmentObject is changed but this changed is only reflected when I change the tab and change it back again
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
I had a similar problem, this is the hack I came up with.
In your "TestView" declare:
#State var needRefresh: Bool = false
Pass this to your "detailView" destination, such as:
NavigationLink(destination: detailView(feed: self._feed, i: i, needRefresh: self.$needRefresh)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}.accentColor(self.needRefresh ? .white : .black)
}
Note ".accentColor(self.needRefresh ? .white : .black)" to force a refresh when "needRefresh"
is changed.
In your "detailView" destination add:
#Binding var needRefresh: Bool
Then in your "detailView" in your Button action, add:
self.needRefresh.toggle()

Show a new View from Button press Swift UI

I would like to be able to show a new view when a button is pressed on one of my views.
From the tutorials I have looked at and other answered questions here it seems like everyone is using navigation button within a navigation view, unless im mistaken navigation view is the one that gives me a menu bar right arrows the top of my app so I don't want that. when I put the navigation button in my view that wasn't a child of NavigationView it was just disabled on the UI and I couldn't click it, so I guess I cant use that.
The other examples I have seen seem to use presentation links / buttons which seem to show a sort of pop over view.
Im just looking for how to click a regular button and show another a view full screen just like performing a segue used to in the old way of doing things.
Possible solutions
1.if you want to present on top of current view(ex: presentation style in UIKit)
struct ContentView: View {
#State var showingDetail = false
var body: some View {
Button(action: {
self.showingDetail.toggle()
}) {
Text("Show Detail")
}.sheet(isPresented: $showingDetail) {
DetailView()
}
}
}
2.if you want to reset current window scene stack(ex:after login show home screen)
Button(action: goHome) {
HStack(alignment: .center) {
Spacer()
Text("Login").foregroundColor(Color.white).bold()
Spacer()
}
}
func goHome() {
if let window = UIApplication.shared.windows.first {
window.rootViewController = UIHostingController(rootView: HomeScreen())
window.makeKeyAndVisible()
}
}
3.push new view (ex: list->detail, navigation controller of UIKit)
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Show Detail View")
}.navigationBarTitle("Navigation")
}
}
}
}
4.update the current view based on #state property, (ex:show error message on login failure)
struct ContentView: View {
#State var error = true
var body: some View {
...
... //login email
.. //login password
if error {
Text("Failed to login")
}
}
}
For simple example you can use something like below
import SwiftUI
struct ExampleFlag : View {
#State var flag = true
var body: some View {
ZStack {
if flag {
ExampleView().tapAction {
self.flag.toggle()
}
} else {
OtherExampleView().tapAction {
self.flag.toggle()
}
}
}
}
}
struct ExampleView: View {
var body: some View {
Text("some text")
}
}
struct OtherExampleView: View {
var body: some View {
Text("other text")
}
}
but if you want to present more view this way looks nasty
You can use stack to control view state without NavigationView
For Example:
class NavigationStack: BindableObject {
let didChange = PassthroughSubject<Void, Never>()
var list: [AuthState] = []
public func push(state: AuthState) {
list.append(state)
didChange.send()
}
public func pop() {
list.removeLast()
didChange.send()
}
}
enum AuthState {
case mainScreenState
case userNameScreen
case logginScreen
case emailScreen
case passwordScreen
}
struct NavigationRoot : View {
#EnvironmentObject var state: NavigationStack
#State private var aligment = Alignment.leading
fileprivate func CurrentView() -> some View {
switch state.list.last {
case .mainScreenState:
return AnyView(GalleryState())
case .none:
return AnyView(LoginScreen().environmentObject(state))
default:
return AnyView(AuthenticationView().environmentObject(state))
}
}
var body: some View {
GeometryReader { geometry in
self.CurrentView()
.background(Image("background")
.animation(.fluidSpring())
.edgesIgnoringSafeArea(.all)
.frame(width: geometry.size.width, height: geometry.size.height,
alignment: self.aligment))
.edgesIgnoringSafeArea(.all)
.onAppear {
withAnimation() {
switch self.state.list.last {
case .none:
self.aligment = Alignment.leading
case .passwordScreen:
self.aligment = Alignment.trailing
default:
self.aligment = Alignment.center
}
}
}
}
.background(Color.black)
}
}
struct ExampleOfAddingNewView: View {
#EnvironmentObject var state: NavigationStack
var body: some View {
VStack {
Button(action:{ self.state.push(state: .emailScreen) }){
Text("Tap me")
}
}
}
}
struct ExampleOfRemovingView: View {
#EnvironmentObject var state: NavigationStack
var body: some View {
VStack {
Button(action:{ self.state.pop() }){
Text("Tap me")
}
}
}
}
In my opinion this bad way, but navigation in SwiftUI much worse