Most of the Apple iOS app have a small indicator at the top of their modal content:
I tried looking for it but I can't even figure out what to call that item.
Here is a Modal View Indicator
import SwiftUI
struct ModalViewIndicator: View {
var body: some View {
HStack {
Spacer()
Image(systemName: "minus")
.imageScale(.large)
.font(Font.title.weight(.heavy))
.foregroundColor(Color(UIColor.tertiaryLabel))
Spacer()
}.padding(4)
}
}
struct ModalViewIndicator_Previews: PreviewProvider {
static var previews: some View {
Text("ModalViewIndicator")
.sheet(isPresented: .constant(true)) {
VStack {
ModalViewIndicator()
GeometryReader { geometry in
Image(systemName: "sun.dust.fill")
.resizable()
.frame(
width: geometry.size.width/2,
height: geometry.size.width/2,
alignment: .center
)
}
}
}
}
}
Perhaps a better approach here is to simply use a rectangle:
HStack {
Spacer()
RoundedRectangle(cornerRadius: CGFloat(5.0) / 2.0)
.frame(width: 40, height: 5)
.foregroundColor(Color(UIColor.tertiaryLabel))
Spacer()
}.padding(4)
I see a couple of great answers in SwiftUI and since the question also has a UIKit tag, I thought I might add some ideas to solve this for UIKit.
The first thing to think about is your view hierarchy, is the indicator going to be part of your navigation bar or perhaps your view does not have navigation bar - so accordingly you probably need some code to find the correct view to add this indicator to.
In my scenario, I needed a navigation bar so my view controllers were within a navigation controller but you could do the same inside your view controllers directly:
1: Subclass a Navigation Controller
This is optional but it would be nice to abstract away all of this customization into the navigation controller.
I do a check to see if the NavigationController is being presented. This might not be the best way to check but since this is not part of the question, refer to these answers to check if a view controller was presented modally or not
class CustomNavigationController: UINavigationController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// this checks if the ViewController is being presented
if presentingViewController != nil {
addModalIndicator()
}
}
private func addModalIndicator() {
let indicator = UIView()
indicator.backgroundColor = .tertiaryLabel
let indicatorSize = CGSize(width: 30, height: 5)
let indicatorX = (navigationBar.frame.width - indicatorSize.width) / CGFloat(2)
indicator.frame = CGRect(origin: CGPoint(x: indicatorX, y: 8), size: indicatorSize)
indicator.layer.cornerRadius = indicatorSize.height / CGFloat(2.0)
navigationBar.addSubview(indicator)
}
}
2: Present the Custom Navigation Controller
let someVC = UIViewController()
let customNavigationController = CustomNavigationController()
customNavigationController.setViewControllers([stationsVC], animated: false)
present(playerNavigationController, animated: true) { }
3: This will produce the following results
You might need to alter some logic here based on your scenario / view controller hierarchy but hopefully this gives you some ideas.
Related
I created a view modifier for reusable custom alert which works as expected. I now want to add a dimmer view in between presenting view and alert view i.e. view behind the alert which covers full screen and disables any clicks on the presenting view.
I tried adding background on the presenting view when alert is presented, but nothing is happening.
Custom alert view modifier, view extension and view model:
import Foundation
import SwiftUI
struct CustomAlertView: ViewModifier {
#Binding var isPresented: Bool
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented
}
func body(content: Content) -> some View {
content.overlay(alertContent())
}
#ViewBuilder
private func alertContent() -> some View {
GeometryReader { geometry in
if self.$isPresented.wrappedValue {
VStack {
Image(systemName: "info.circle").resizable().frame(width: 30.0, height: 30.0).padding(.top, 30).foregroundColor(.cyan)
Text("Error title").foregroundColor(Color.black).font(.title2).bold().lineLimit(nil).padding([.leading, .trailing], 24.0).padding(.top, 16.0)
Spacer()
Text("There was an error while processing your request.").foregroundColor(Color.black).font(.body).lineLimit(nil).padding([.leading, .trailing], 18.0).padding(.top, 16.0)
Spacer()
Button(action: { self.$isPresented.wrappedValue.toggle() }) {
Text("Ok").foregroundColor(.white).font(.largeTitle).bold()
}.padding(.bottom, 25.0)
}.fixedSize(horizontal: false, vertical: true)
.background(Color.purple)
.cornerRadius(10)
.clipped()
.padding([.leading, .trailing], 5.0)
.position(x: geometry.size.width/2, y: geometry.size.height/2)
.frame(width: 328.0)
}
}
}
}
extension View {
func customAlert(isPresented: Binding<Bool>) -> some View {
return modifier(CustomAlertView(isPresented: isPresented))
}
}
class CustomViewModel: ObservableObject {
#Published var showAlert = false
func doSomething() {
// Sets showAlert to true incase of network disconnect or some query failure.
self.showAlert = true
}
}
Content view:
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: CustomViewModel = CustomViewModel()
var body: some View {
VStack {
Spacer()
Button(action: { viewModel.doSomething() }) {
Text("Start").foregroundColor(Color.red).font(.title)
}.padding(.bottom, 100.0)
}
.background(viewModel.showAlert ? Color.gray : Color.clear)
.customAlert(isPresented: $viewModel.showAlert)
}
}
Here, ContentView is the presenting view since it's what is presenting the alert. I want to add a grayish sort of view/dimmer view covering full screen and it will be below the presented alert. When dimmer view is present and I click on "Start" button in ContentView, it should be disabled. I don't know if I can achieve this by modifying the custom alert view modifier, hence I was trying to add a background color to ContentView, but nothing seems to be happening. I have too much of code in the view model and content view, so I removed most of it and kept what I thought was needed.
How do I achieve it?
I was able to add a view behind the custom alert view modifier. Adding code here incase if someone comes looking for it in future.
I removed ".background(viewModel.showAlert ? Color.gray : Color.clear)" from my content view since I eventually wanted the logic to be part of custom alert view modifier and not add it to every view. In my customAlertView, I modified the body function as below:
func body(content: Content) -> some View {
content.overlay(self.$isPresented.wrappedValue ? Color.gray.ignoresSafeArea() : nil)
.overlay(self.$isPresented.wrappedValue ? alertContent() : nil)
}
I'm not sure if this is a bug with the iOS 16 betas (currently running iOS Developer Beta 3 on Xcode 14 beta 3) or if I'm doing something wrong (most likely the latter).
I'm trying to add a SwiftUI view to my existing UIKit app which uses UINavigationController. When I push the SwiftUI view (using UIHostingController), the destination (SwiftUI) view has this weird animation bug where the content loads for a split second, then it will jump up to the correct position. I'm setting the title for the NavigationBar to be .inline or blank completely and it seems like the title is initially being rendered in the .large format then, once it changes it to .inline, the content moves up to fill in the space once taken by the Title.
I've created a sample app to show this and submitted the bug report to Apple, but on the good chance I'm screwing something up, I'm hoping someone has any idea since I'm not likely to get a response from Apple.
Here's the sample project: https://github.com/rjeitani/testNavBarIssue
Relevant code:
// The UIKit ViewController
func showSwiftUIView() {
let vc = UIHostingController(rootView: SwiftUIView())
self.navigationController?.pushViewController(vc, animated: true)
}
// SwiftUI View
import SwiftUI
import Charts
struct SwiftUIView: View {
var body: some View {
testView()
}
}
struct testView: View {
#State private var pickerState: Bool = false
var body: some View {
VStack (alignment: .leading) {
Text("Test").padding()
Chart {
BarMark(x: .value("Month", 1), y: .value("Count", 3))
BarMark(x: .value("Month", 2), y: .value("Count", 7))
BarMark(x: .value("Month", 3), y: .value("Count", 2))
BarMark(x: .value("Month", 4), y: .value("Count", 1))
}
} .navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIView()
}
}
Here's a screen recording of the bug. You can see near the end that the "Test" label and the whole Chart itself jump upwards after the view has loaded.
Faced the same issue, fixed it by avoiding SwiftUI's navigation viewModifiers (.navigationTitle() and .navigationBarTitle()) altogether and instead using UIKit's functions after UIHostingController is initialized.
In your case that would be inside the showSwiftUI() func like so:
func showSwiftUIView() {
let vc = UIHostingController(rootView: SwiftUIView())
// This sets the NavigationBar title
vc.title = ""
// This sets NavigationBar title to be "inline"
vc.navigationItem.largeTitleDisplayMode = .never
self.navigationController?.pushViewController(vc, animated: true)
}
Alternatively I highly recommend you use Coordinator pattern along with UIKit-SwiftUI navigation as it'll help abstract away the navigation logic along with navigation-specific view modifications in a separate file.
Problem
Hello, I need help with a SwiftUI View that is presented by a View Controller that is configured in a storyboard.
I present a SwiftUI View including its own model using a UIHostingController as described in this post: addSubView SwiftUI View to UIKit UIView in Swift.
Now my view is shrunken inside its Hosting View Controller's view property to its content size.
What I have tried
Wrapping a ContainerView inside the Parent View Controller into two StackViews to force the view to use the whole screen (vertical and horizontal)
Configuring the initial VStack inside my SwiftUI View with .frame(minWidth: 0, maxWidth: .infinity)
Using an initial HStack with a Spacer() beneath and below all elements within that stack as suggested in Make a VStack fill the width of the screen in SwiftUI
Some Code
View Controller:
class SomeParentViewController: UIViewController {
var detailsView: DetailsOverlayView? // --> SwiftUI View
var detailsViewModel: DetailsOverlayViewModel? // --> Its model
var childVC: UIHostingController<DetailsOverlayView>? // --> Child VC to present View
// inside UIView
var detailData: DetailData?
override func viewDidLoad() {
super.viewDidLoad()
if let data = detailData {
model = DetailsOverlayViewModel(data: data)
}
if let m = model {
detailsView = DetailsOverlayView(viewModel: m)
}
if let v = paymentDetailsView {
child = UIHostingController(rootView: v)
child?.view.translatesAutoresizingMaskIntoConstraints = false
child?.view.frame = self.view.bounds
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let cvc = childVC {
self.addSubview(cvc.view)
self.addChild(cvc)
}
}
...
}
The SwiftUI View:
...
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Title
HStack {
Text("My great and awesome title")
}
VStack(alignment: .leading, spacing: 0) {
Text("My even more awesome subtitle")
HStack(alignment: .top) {
Text("on the left 1")
Spacer()
Text("on the right 1")
}
HStack(alignment: .top) {
Text("on the left 2")
Spacer()
Text("on the right 2")
}
Divider()
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding(16)
}
...
A Picture of the Result
Well considering a SwiftUI view wrapped in a UIHostingController acts the same way a UIViewController does, you would define autolayout constraints the same way.
Having declared translatesAutoresizingMaskIntoConstraints = false, I'm going to assume that's the plan. In that case, what I'd suggest is the following:
NSLayoutConstraint.activate([
child.view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
child.view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
child.view.widthAnchor.constraint(equalTo: self.view.widthAnchor),
child.view.heightAnchor.constraint(equalTo: self.view.heighAnchor)
])
The above makes the UIHostingController the same size as the parent controller and centered to it. The height and width anchors can be constants such that you'd do .constraint(equalToConstant: 150), or whatever other variation you'd prefer.
I've recently started working in SwiftUI, came to the conclusion that working with navigation isn't really great yet. What I'm trying to achieve is the following. I finally managed to get rid of the translucent background without making the application crash, but now I ran into the next issue. How can I get rid of the "back" text inside the navbaritem?
I achieved the view above by setting the default appearance in the SceneDelegate.swift file like this.
let newNavAppearance = UINavigationBarAppearance()
newNavAppearance.configureWithTransparentBackground()
newNavAppearance.setBackIndicatorImage(UIImage(named: "backButton"), transitionMaskImage: UIImage(named: "backButton"))
newNavAppearance.titleTextAttributes = [
.font: UIFont(name: GTWalsheim.bold.name, size: 18)!,
.backgroundColor: UIColor.white
]
UINavigationBar.appearance().standardAppearance = newNavAppearance
One possible way that I could achieve this is by overriding the navigation bar items, however this has one downside (SwiftUI Custom Back Button Text for NavigationView) as the creator of this issue already said, the back gesture stops working after you override the navigation bar items. With that I'm also wondering how I could set the foregroundColor of the back button. It now has the default blue color, however I'd like to overwrite this with another color.
Piggy-backing on the solution #Pitchbloas offered, this method just involves setting the backButtonDisplayMode property to .minimal:
extension UINavigationController {
open override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
It's actually really easy. The following solution is the fastest and cleanest i made.
Put this at the bottom of your SceneDelegate for example.
extension UINavigationController {
// Remove back button text
open override func viewWillLayoutSubviews() {
navigationBar.topItem?.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
}
}
This will remove the back button text from every NavigationView (UINavigationController) in your app.
I have found a straightforward approach to remove the back button text using SwiftUI only, and keeping the original chevron.
A drag gesture is added to mimic the classic navigation back button
when user wants to go back by swiping right. Following this, an extension of View is created to create a SwiftUI like modifier.
This is how to use it in code:
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
// Your main view code here with a ZStack to have the
// gesture on all the view.
}
.navigationBarBackButtonTitleHidden()
}
}
This is how to create the navigationBarBackButtonTitleHidden() modifier:
import SwiftUI
extension View {
func navigationBarBackButtonTitleHidden() -> some View {
self.modifier(NavigationBarBackButtonTitleHiddenModifier())
}
}
struct NavigationBarBackButtonTitleHiddenModifier: ViewModifier {
#Environment(\.dismiss) var dismiss
#ViewBuilder #MainActor func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading: Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.foregroundColor(.blue)
.imageScale(.large) })
.contentShape(Rectangle()) // Start of the gesture to dismiss the navigation
.gesture(
DragGesture(coordinateSpace: .local)
.onEnded { value in
if value.translation.width > .zero
&& value.translation.height > -30
&& value.translation.height < 30 {
dismiss()
}
}
)
}
}
Standard Back button title is taken from navigation bar title of previous screen.
It is possible the following approach to get needed effect:
struct TestBackButtonTitle: View {
#State private var hasTitle = true
var body: some View {
NavigationView {
NavigationLink("Go", destination:
Text("Details")
.onAppear {
self.hasTitle = false
}
.onDisappear {
self.hasTitle = true
}
)
.navigationBarTitle(self.hasTitle ? "Master" : "")
}
}
}
So I actually ended up with the following solution that actually works. I am overwriting the navigation bar items like so
.navigationBarItems(leading:
Image("backButton")
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
)
The only issue with this was that the back gesture wasn't working so that was solved by actually extending the UINavigationController
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
Now it's looking exactly the way I want it, the solution is kinda hacky... but it works for now, hopefully SwiftUI will mature a little bit so this can be done easier.
Using the Introspect framework, you can easily gain access to the underlying navigation item and set the backButtonDisplayMode to minimal.
Here’s how you might use that in the view that was pushed
var body: some View {
Text("Your body here")
.introspectNavigationController { navController in
navController.navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
If you want to:
Do it globally
Keep the standard back button (along with custom behaviours like an ability to navigate a few screens back on a long press)
Avoid introducing any third party frameworks
You can do it by setting the back button text color to Clear Color via appearance:
let navigationBarAppearance = UINavigationBarAppearance()
let backButtonAppearance = UIBarButtonItemAppearance(style: .plain)
backButtonAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.highlighted.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear]
navigationBarAppearance.backButtonAppearance = backButtonAppearance
//Not sure you'll need both of these, but feel free to adjust to your needs.
UINavigationBar.appearance().standardAppearance = navigationBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
You can do it once when the app starts and forget about it.
A potential downside (depending on your preferences) is that the transition to the clear color is animated as the title of the current window slides to the left as you move to a different one.
You can also experiment with different text attributes.
Works on iOS 16
Solutions above didn't work for me. I wanted to make changes specific to view without any global (appearance or extension) and with minimal boilerplate code.
Since you can update NavigationItem inside the init of the View. You can solve this in 2 steps:
Get visible View Controller.
// Get Visible ViewController
extension UIApplication {
static var visibleVC: UIViewController? {
var currentVC = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
while let presentedVC = currentVC?.presentedViewController {
if let navVC = (presentedVC as? UINavigationController)?.viewControllers.last {
currentVC = navVC
} else if let tabVC = (presentedVC as? UITabBarController)?.selectedViewController {
currentVC = tabVC
} else {
currentVC = presentedVC
}
}
return currentVC
}
}
Update NavigationItem inside init of the View.
struct YourView: View {
init(hideBackLabel: Bool = true) {
if hideBackLabel {
// iOS 14+
UIApplication.visibleVC?.navigationItem.backButtonDisplayMode = .minimal
// iOS 13-
let button = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
UIApplication.visibleVC?.navigationItem.backBarButtonItem = button
}
}
}
custom navigationBarItems and self.presentationMode.wrappedValue.dismiss() worked but you are not allow to perform swiping back
You can either add the following code to make the swipe back again
//perform gesture go back
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
but the problem is, sometimes it will make your app crashed when you swipe half the screen and then cancel.
I would suggest the other way to remove the "Back" text.
Adding the isActive state to monitor whether the current screen is active or not. :)
struct ContentView: View {
#State var isActive = false
var body: some View {
NavigationView() {
NavigationLink(
"Next",
destination: Text("Second Page").navigationBarTitle("Second"),
isActive: $isActive
)
.navigationBarTitle(!isActive ? "Title" : "", displayMode: .inline)
}
}
}
I am accomplishing this by changing the title of the master screen before pushing the detail screen and then setting it back when it re-appears. The only caveat is when you go back to the master screen the title's re-appearance is a little noticeable.
Summary:
on master view add state var (e.g. isDetailShowing) to store if detail screen is showing or not
on master view use the navigationTitle modifier to set the title based on the current value of isDetailShowing
on master view use onAppear modifier to set the value of isDetailShowing to false
on the NavigationLink in master screen use the simultaneousGesture modifier to set the isDetailShowing to true
struct MasterView: View {
#State var isDetailShowing = false
var body: some View {
VStack {
Spacer()
.frame(height: 20)
Text("Master Screen")
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
.frame(height: 20)
NavigationLink(destination: DetailView()) {
Text("Go to detail screen")
}
.simultaneousGesture(TapGesture().onEnded() {
isDetailShowing = true
})
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(isDetailShowing ? "" : "Master Screen Title")
.onAppear() {
isDetailShowing = false
}
}
}
struct DetailView: View {
var body: some View {
Text("This is the detail screen")
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Detail Screen Title")
}
}
you can use .toolbarRole(.editor)
Why not use Custom BackButton with Default Back Button Hidden
You could use Any Design You Prefer !
Works on iOS 16
First View
struct ContentView: View {
var body: some View {
NavigationView {
VStack(){
Spacer()
NavigationLink(destination: View2()) {
Text("Navigate")
.font(.title)
}
Spacer()
}
}
}
}
Second View
struct View2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
ZStack{
VStack{
HStack(alignment:.center){
//Any Design You Like
Image(systemName: "chevron.left")
.font(.title)
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
.padding()
Spacer()
}
Spacer()
}
}
}
.navigationBarBackButtonHidden(true)
}
}
Anyone knows how to to make the modal view with a transparent background.
Exactly like the below link in swift
Swift Modal View Controller with transparent background
I am using coordinator pattern, creating view in assembly
let view = UIHostingController(rootView: swiftuiview)
view.view.backgroundColor = .clear
inside router just present this UIHostingController
module.modalPresentationStyle = .overCurrentContext
navigationController.present(module, animated: animated, completion: nil)
If you wanting to blur the background of a SwiftUI from UIKit Project and are possibly using SwiftUI View for a Modal View then I had the same problem recently and created a UIViewController that takes the UIHostController (UIViewController basically), then alters the HostingController View's alpha, puts a blur at the back and presents it to the parent view.
I Have created a gist with the file in it for public use
https://gist.github.com/Ash-Bash/93fd55d89c1e36f592d3868f6b29b259
Heres the working example:
// Initialises BlurredHostingController
var blurredHostingController = BlurredHostingController()
// Sets the Hosting View for the SwiftUI View Logic
blurredHostingController.hostingController = UIHostingController(rootView: ContentView())
// Blur Tweaks for blurredHostingController
blurredHostingController.blurEffect = .systemMaterial
blurredHostingController.translucentEffect = .ultrathin
// Presents View Controller as a Modal View Controller
self.present(blurredHostingController animated: true, completion: nil)
Here's the result from macCatalyst
Present:
let rootView = Text("Hello world")
let controller = UIHostingController(rootView: rootView)
controller.view.backgroundColor = .clear
UIApplication.shared.windows.first?.rootViewController?.present(controller, animated: true)
Dismiss:
UIApplication.shared.windows.first?.rootViewController?.dismiss(animated: true)
I didn't get the ideal way to do so, but I got a workaround for this.
So, In order to present a view modally, you can take a ZStack and group multiple views in it and handle it with a #State variable like this.
Here I have given the background colour to the view for better explanation.
struct ContentView : View {
#State private var showModally = false
var body : some View {
ZStack {
Color.red
VStack {
Button(action: {
withAnimation{
self.showModally = true
}
}) {
Text("Push Modally")
}
}
ModalView(show: $showModally)
.offset(y: self.showModally ? 0 : UIScreen.main.bounds.height)
.animation(.spring())
}
}
}
struct ModalView: View {
#Binding var show : Bool
var body: some View {
VStack {
Spacer()
VStack {
Color.white
}
.frame(height : 400)
.cornerRadius(10)
.padding(.horizontal)
}
.background(Color.clear)
.onTapGesture {
self.show = false
}
}
}
In this, the Modal View will be presented modally over the content view and will be dismissed by a tap.