I'm using SwiftUI's TabView and I want to add a custom bottomSheet() modifier which accepts a view and displays it like the standard sheet() modifier, but without occupying the entire screen.
Current Behaviour: I managed to create the custom modifier and show a sheet, but the sheet comes up behind the bottom tab bar (since it is displayed from within the NavigationView).
Expected Behavior: I'm looking for a way to cover the tab bar with the sheet in front.
Minimal Reproducible Example
Here's the custom modifier that I've created.
struct BottomSheet<SheetContent: View>: ViewModifier {
let sheetContent: SheetContent
#Binding var isPresented: Bool
init(isPresented: Binding<Bool>, #ViewBuilder content: () -> SheetContent) {
self.sheetContent = content()
_isPresented = isPresented
}
func body(content: Content) -> some View {
ZStack {
content
if isPresented {
ZStack {
Color.black.opacity(0.1)
VStack {
Spacer()
sheetContent
.padding()
.frame(maxWidth: .infinity)
.background(
Rectangle()
.foregroundColor(.white)
)
}
}
}
}
}
}
extension View {
func bottomSheet<SheetContent: View>(isPresented: Binding<Bool>, #ViewBuilder content: #escaping () -> SheetContent) -> some View {
self.modifier(BottomSheet(isPresented: isPresented, content: content))
}
}
Here's how I'm using it.
struct ScheduleTab: View {
#State private var showSheet = false
var body: some View {
NavigationView {
Button("Open Sheet") {
showSheet.toggle()
}
}
.navigationTitle("Today")
.navigationBarTitleDisplayMode(.inline)
.bottomSheet(isPresented: $showSheet) {
Text("Hello, World")
}
}
}
Related
I'm trying to create a custom TabView in SwiftUI, that also has a .tabViewStyle(.page()) functionality too.
At the moment I'm 99% of the way there, but cannot figure out how to get all the TabBarItems to list.
I'm using the PreferenceKey so that the order I add them into the closure is the order in the TabView.
When I run it, the tab items are added to the array, then removed, and it doesn't seem to be working.
I had it working with the enum as CaseIterable and the ForEach(tabs) { tab in as ForEach(TabBarItems.allCases) { tab in, but as mentioned wanted the order in the bar to be organic from the clousure.
Container
struct TabViewContainer<Content : View>: View {
#Binding private var selection: TabBarItem
#State private var tabs: [TabBarItem] = []
var content: Content
init(selection: Binding<TabBarItem>, #ViewBuilder content: () -> Content) {
self._selection = selection
self.content = content()
}
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selection) {
content
}
.tabViewStyle(.page(indexDisplayMode: .never))
tabBarItems()
}
.onPreferenceChange(TabBarItemsPreferenceKey.self) { self.tabs = $0 }
}
private func tabBarItems() -> some View {
HStack(spacing: 10) {
ForEach(tabs) { tab in
Button {
selection = tab
} label: {
tabButton(tab: tab)
}
}
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
.padding(.top, 8)
.background(Color(uiColor: .systemGray6))
}
private func tabButton(tab: TabBarItem) -> some View {
VStack(spacing: 0) {
Image(icon: tab.icon)
.font(.system(size: 16))
.frame(maxWidth: .infinity, minHeight: 28)
Text(tab.title)
.font(.system(size: 10, weight: .medium, design: .rounded))
}
.foregroundColor(selection == tab ? tab.colour : .gray)
}
}
PreferenceKey / Modifier
struct TabBarItemsPreferenceKey: PreferenceKey {
static var defaultValue: [TabBarItem] = []
static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
value += nextValue()
}
}
struct TabBarItemViewModifier: ViewModifier {
let tab: TabBarItem
func body(content: Content) -> some View {
content.preference(key: TabBarItemsPreferenceKey.self, value: [tab])
}
}
extension View {
func tabBarItem(_ tab: TabBarItem) -> some View {
modifier(TabBarItemViewModifier(tab: tab))
}
}
Demo view
struct TabSelectionView: View {
#State private var selection: TabBarItem = .itinerary
var body: some View {
TabViewContainer(selection: $selection) {
PhraseView()
.tabBarItem(.phrases)
ItineraryView()
.tabBarItem(.itinerary)
BudgetView()
.tabBarItem(.budget)
BookingView()
.tabBarItem(.bookings)
PackingListView()
.tabBarItem(.packing)
}
}
}
Intended
Current
You can use a more elegant way, #resultBuilder:
You create a struct that holds the View & the tag;
tabBarItem should now return the previously created struct;
The #resultBuilder will then build your array of your view & tag which you'll be using inside the container.
ResultBuilder:
#resultBuilder
public struct TabsBuilder {
internal static func buildBlock(_ components: Tab...) -> [Tab] {
return components
}
internal static func buildEither(first component: Tab) -> Tab {
return component
}
internal static func buildEither(second component: Tab) -> Tab {
return component
}
}
Tab:
struct Tab: Identifiable {
var content: AnyView //I don't recommend the use of AnyView, but I don't want to dive deep into generics for now.
var tag: TabBarItem
var id = UUID()
}
Modifier:
struct Tab: Identifiable {
var content: AnyView
var tag: TabBarItem
var id = UUID()
}
TabViewContainer:
struct TabViewContainer: View {
#Binding private var selection: TabBarItem
#State private var tabs: [TabBarItem]
var content: [Tab]
init(selection: Binding<TabBarItem>, #TabsBuilder content: () -> [Tab]) {
self._selection = selection
self.content = content()
self.tabs = self.content.map({$0.tag})
}
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selection) {
ForEach(content) { content in
content.content
.tag(content.tag)
}
}.tabViewStyle(.page(indexDisplayMode: .never))
tabBarItems()
}
}
private func tabBarItems() -> some View {
HStack(spacing: 10) {
ForEach(tabs) { tab in
Button {
selection = tab
} label: {
tabButton(tab: tab)
}
}
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
.padding(.top, 8)
.background(Color(uiColor: .systemGray6))
}
private func tabButton(tab: TabBarItem) -> some View {
VStack(spacing: 0) {
Image(icon: tab.icon)
.font(.system(size: 16))
.frame(maxWidth: .infinity, minHeight: 28)
Text(tab.title)
.font(.system(size: 10, weight: .medium, design: .rounded))
}
.foregroundColor(selection == tab ? tab.colour : .gray)
}
}
I have an app, which displays logo in the center of the NavigationBar in SwiftUI. The app is using NavigationStack in iOS 16. Here is the implementation.
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
NavigationLink("Detail", value: "Detail")
.toolbar {
ToolbarItem(placement: .principal) {
Image(systemName: "heart.fill")
.foregroundColor(.red)
}
}
}.navigationDestination(for: String.self) { stringValue in
DetailView()
}
}
}
}
This displays the heart logo in the center of the NavigationBar but as soon as I go to DetailView, the heart logo is gone. How can I make sure that the heart logo is available on app the navigation bars in the app.
UPDATE: I can solve this problem by creating a custom NavigationContainerView as shown below:
struct CustomNavigationView<Header: View, Content: View>: View {
let header: Header
let content: () -> Content
init(header: Header, content: #escaping () -> Content) {
self.header = header
self.content = content
}
var body: some View {
VStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
NavigationStack {
content()
.navigationDestination(for: String.self) { stringValue in
Text("Detail")
}
}
.toolbar {
ToolbarItem(placement: .principal) {
header
}
}
}
}
}
But this creates a small gap between the NavigationBar and the back button as shown below:
UPDATE: ZStack approach does not show the image at all.
struct CustomNavigationView<Content: View>: View {
let content: () -> Content
init(content: #escaping () -> Content) {
self.content = content
}
var body: some View {
ZStack(alignment: .top) {
Image(systemName: "heart.fill")
.foregroundColor(.red)
NavigationStack {
content()
.navigationDestination(for: String.self) { stringValue in
Text("Detail")
}
}
}
}
}
struct CustomNavigationView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
CustomNavigationView {
Text("DETAIL")
}
}
}
}
A possible approach is to use instead ZStack with top alignment, like
var body: some View {
ZStack(alignment: .top) { // << here !!
Image(systemName: "heart.fill")
.foregroundColor(.red).zIndex(1)
NavigationStack {
content()
.navigationDestination(for: String.self) { stringValue in
Text("Detail")
}
}
I have a ScrollView with a ForEach loop, each rendering a View. In the View I have 3 renders of the below ActionItem (a button that displays a sheet). The sheet does not show up with ScrollView but does with List. I'd normally attach the .sheet at the ScrollView layer however, with each button rendering a different view it seems more appropriate to nest it.
How I could get this to work with ScrollView? I'm using Xcode 12
struct ActionItem<Content>: View where Content : View {
public var text: String
public var icon: String
public var content: Content
#State var isPresented = false
init(text: String, icon: String, #ViewBuilder content: () -> Content) {
self.text = text
self.icon = icon
self.content = content()
}
var body: some View {
Button (action: {
DispatchQueue.main.async {
withAnimation {
self.isPresented = true
}
}
}) {
HStack(spacing: 2) {
Image(systemName: icon).font(.system(size: 14, weight: .semibold))
Text(text).fontWeight(.semibold)
}.padding([.top, .bottom], Dimensions.spacing)
.padding([.leading, .trailing], Dimensions.spacingMedium)
}.foregroundColor(Color.gray).font(.subheadline).background(Color.grayWhiteTer)
.cornerRadius(Dimensions.spacing)
.sheet(isPresented: $isPresented) {
self.content
}
}
}
In the View I'd render ActionItem such as Text, this also occurs if the View is ignored and the ActionItem is just directly in the ForEach. Same issue, sheet does not appear.
ActionItem(text: "", icon: "pencil") {
Text("ok")
}
The list looks like this
import SwiftUI
struct ItemsList: View {
#ObservedObject var itemModel: ItemModel
var body: some View {
VStack(alignment: .center, spacing: 0) {
ScrollView {
VStack {
ForEach(itemModel.items, id: \.self) { item in
ItemView(item: item)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}}
Suggested callback update
struct ActionItem<Content>: View where Content : View {
public var text: String
public var icon: String
public var content: () -> Content
#State var isPresented = false
init(text: String, icon: String, #ViewBuilder content: #escaping () -> Content) {
self.text = text
self.icon = icon
self.content = content
}
var body: some View {
Button (action: {
DispatchQueue.main.async {
withAnimation {
self.isPresented = true
}
}
}) {
HStack(spacing: 2) {
Image(systemName: icon).font(.system(size: 14, weight: .semibold))
Text(text).fontWeight(.semibold)
}.padding([.top, .bottom], Dimensions.spacing)
.padding([.leading, .trailing], Dimensions.spacingMedium)
}.foregroundColor(Color.gray).font(.subheadline).background(Color.grayWhiteTer)
.cornerRadius(Dimensions.spacing)
.sheet(isPresented: $isPresented) {
self.content()
}
}
}
Try saving content as a callback (i.e. () -> Content) and call it in the sheet method instead of calling it in the initializer.. This will change when the view is created.
I'm creating a view embedded in a ZStack, something like this:
ZStack(alignment: .top) {
content
if self.show {
VStack {
HStack {
This is a viewModifier so I call this in my main view with for example: .showView().
But what happened is that if I have a NavigationView, this view is only showing below the navigationView. (I have a navigationViewTitle that is over my view).
How can I solve this problem? I was thinking about some zIndex but it is not working. I thought also about some better placement of this .showView(), but nothing to do.
Here is a demo of possible approach (it can be added animations/transitions, but it is out of topic). Demo prepared & tested with Xcode 11.4 / iOS 13.4
struct ShowViewModifier<Cover: View>: ViewModifier {
let show: Bool
let cover: () -> Cover
func body(content: Content) -> some View {
ZStack(alignment: .top) {
content
if self.show {
cover()
}
}
}
}
struct DemoView: View {
#State private var isPresented = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Link", destination: Button("Details")
{ self.isPresented.toggle() })
Text("Some content")
.navigationBarTitle("Demo")
Button("Toggle") { self.isPresented.toggle() }
}
}
.modifier(ShowViewModifier(show: isPresented) {
Rectangle().fill(Color.red)
.frame(height: 200)
})
}
}
As the title says, NavigationLink (or Button) does not receive tap before the horizontal scrollview has been scrolled, i.e. nothing is tappable/reponsive. After scrolling everything works as expected.
I am using Xcode 12 Beta 2 with the API of iOS 13 (can not use iOS 14 API due to the clients needs)
struct ARQuickMenuChooseArt: View {
let title: String
let favorites: [Artwork]
var donePressed: (() -> Void)?
var seeAllArtPressed: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 24) {
Text("Favourites")
.font(.title)
.padding(.bottom, -10)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(favorites) { favorite in
NavigationLink(destination: Text("Destination View")) {
ARQuickMenuArtListItem(artwork: favorite)
}
}
}
}
}
}
}
Here is the view that uses the view containing the scrollview.
struct AddArtFlowView: View {
let controller: ARArtworksModelController
#Binding var position: CardPosition
#EnvironmentObject var appState: AppState
var body: some View {
ARMenuContainer(position: $position, dismissAction: dismiss) {
NavigationView {
ARQuickMenuChooseArt(
title: "Add artwork",
favorites: artworkData,
seeAllArtPressed: {
appState.activeView = .menuOpen(.add)
}
)
}
}
}
}
ARMenuContainer is simply a wrapper, seen here:
struct ARMenuContainer<Content>: View where Content: View {
#Binding var position: CardPosition
let dismissAction: () -> Void
let content: () -> Content
init(position: Binding<CardPosition>, dismissAction: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self._position = position
self.dismissAction = dismissAction
self.content = content
}
var body: some View {
SlideOverCard($position, backgroundTapped: dismissAction) {
VStack {
content()
Spacer()
}
.padding(.top, -3)
}
}
}
Which in turn uses a SlideOverCard https://github.com/moifort/swiftUI-slide-over-card, which is used to display above an AR experience.
Here is how it looks in-app
Any ideas why it will not respond without first scrolling?