Related
I'm using .contextMenu together with .onDrag on a view and this seems to be very tricky:
The background color changes to gray by setting dragging to true. This is triggered by .onDrag which already happens when opening the context menu (a bit early but ok). When I use the button to close the menu I can set dragging to false. When I use the drag, the dragging state is changed back to false when the ItemProvider is deinitialized. So far so good.
The problem
When I tap outside the context menu to dismiss it, I seem to have no way to set the dragging state back to false. Adding .onDisappear to the Button in the menu does not work.
What am I doing wrong here? Any way I can get either get notified when the context menu closes or have the state change of dragging happen when the drag actually begins (so that the background is not immediately gray when the context menu is opened)?
Code below video.
struct ContentView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
.contextMenu {
Button("Close Menu") {
dragging = false
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
class ItemProvider: NSItemProvider {
var didEnd: (() -> Void)?
deinit {
print("deinit ItemProvider")
didEnd?()
}
}
EDIT (Dec. 2022): It seems like the code works in iOS 16.2. I still haven't found a good solution to this for earlier iOS versions.
Since you mention UIKit, .contextMenu is a UIContextMenuInteraction
You can add a UIContextMenuInteraction to a SwiftUI View and have access to UIContextMenuInteractionDelegate to identify when the menu is dismissed.
SwiftUI View > ViewModifier > UIViewRepresentable > Coordinator/UIContextMenuInteractionDelegate
struct CustomContextMenuView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
//Use custom context menu and add actions as [UIAction]
.contextMenu(actions: [
UIAction(title: "Close Menu", handler: { a in
print("Close Menu action")
dragging = false
})
], willEnd: {
//Called when the menu is dismissed
print("willEnd/onDismiss")
dragging = false
}, willDisplay: {
//Called when the menu appears
print("willDisplay/onAppear")
})
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
The Menu can be implemented by pasting the code below in a .swift file and using it like the sample above.
extension View {
func contextMenu(actions: [UIAction], willEnd: (() -> Void)? = nil, willDisplay: (() -> Void)? = nil) -> some View {
modifier(ContextMenuViewModifier(actions: actions, willEnd: willEnd, willDisplay: willDisplay))
}
}
struct ContextMenuViewModifier: ViewModifier {
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func body(content: Content) -> some View {
Interaction_UI(view: {content}, actions: actions, willEnd: willEnd, willDisplay: willDisplay)
.fixedSize()
}
}
struct Interaction_UI<Content2: View>: UIViewRepresentable{
typealias UIViewControllerType = UIView
#ViewBuilder var view: Content2
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIView(context: Context) -> some UIView {
let v = UIHostingController(rootView: view).view!
context.coordinator.contextMenu = UIContextMenuInteraction(delegate: context.coordinator)
v.addInteraction(context.coordinator.contextMenu!)
return v
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate{
var contextMenu: UIContextMenuInteraction!
let parent: Interaction_UI
init(parent: Interaction_UI) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [self]
suggestedActions in
return UIMenu(title: "", children: parent.actions)
})
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willDisplay?()
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willEnd?()
}
}
}
struct ContentView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
.contextMenu {
Button("Close Menu") {
dragging = false
}
}
.onTapGesture {
dragging = false
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
class ItemProvider: NSItemProvider {
var didEnd: (() -> Void)?
deinit {
print("deinit ItemProvider")
didEnd?()
}
}
I am new to SwiftUI. I have three views and I want them in a PageView. I want to move each Views by swipe like a pageview and I want the little dots to indicate in which view I'm in.
iOS 15+
In iOS 15 a new TabViewStyle was introduced: CarouselTabViewStyle (watchOS only).
Also, we can now set styles more easily:
.tabViewStyle(.page)
iOS 14+
There is now a native equivalent of UIPageViewController in SwiftUI 2 / iOS 14.
To create a paged view, add the .tabViewStyle modifier to TabView and pass PageTabViewStyle.
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
TabView {
FirstView()
SecondView()
ThirdView()
}
.tabViewStyle(PageTabViewStyle())
}
}
}
You can also control how the paging dots are displayed:
// hide paging dots
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
You can find a more detailed explanation in this link:
How to create scrolling pages of content using tabViewStyle()
Vertical variant
TabView {
Group {
FirstView()
SecondView()
ThirdView()
}
.rotationEffect(Angle(degrees: -90))
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.rotationEffect(Angle(degrees: 90))
Custom component
If you're tired of passing tabViewStyle every time you can create your own PageView:
Note: TabView selection in iOS 14.0 worked differently and that's why I used two Binding properties: selectionInternal and selectionExternal. As of iOS 14.3 it seems to be working with just one Binding. However, you can still access the original code from the revision history.
struct PageView<SelectionValue, Content>: View where SelectionValue: Hashable, Content: View {
#Binding private var selection: SelectionValue
private let indexDisplayMode: PageTabViewStyle.IndexDisplayMode
private let indexBackgroundDisplayMode: PageIndexViewStyle.BackgroundDisplayMode
private let content: () -> Content
init(
selection: Binding<SelectionValue>,
indexDisplayMode: PageTabViewStyle.IndexDisplayMode = .automatic,
indexBackgroundDisplayMode: PageIndexViewStyle.BackgroundDisplayMode = .automatic,
#ViewBuilder content: #escaping () -> Content
) {
self._selection = selection
self.indexDisplayMode = indexDisplayMode
self.indexBackgroundDisplayMode = indexBackgroundDisplayMode
self.content = content
}
var body: some View {
TabView(selection: $selection) {
content()
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: indexDisplayMode))
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: indexBackgroundDisplayMode))
}
}
extension PageView where SelectionValue == Int {
init(
indexDisplayMode: PageTabViewStyle.IndexDisplayMode = .automatic,
indexBackgroundDisplayMode: PageIndexViewStyle.BackgroundDisplayMode = .automatic,
#ViewBuilder content: #escaping () -> Content
) {
self._selection = .constant(0)
self.indexDisplayMode = indexDisplayMode
self.indexBackgroundDisplayMode = indexBackgroundDisplayMode
self.content = content
}
}
Now you have a default PageView:
PageView {
FirstView()
SecondView()
ThirdView()
}
which can be customised:
PageView(indexDisplayMode: .always, indexBackgroundDisplayMode: .always) { ... }
or provided with a selection:
struct ContentView: View {
#State var selection = 1
var body: some View {
VStack {
Text("Selection: \(selection)")
PageView(selection: $selection, indexBackgroundDisplayMode: .always) {
ForEach(0 ..< 3, id: \.self) {
Text("Page \($0)")
.tag($0)
}
}
}
}
}
Page Control
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
#Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.pageIndicatorTintColor = UIColor.lightGray
control.currentPageIndicatorTintColor = UIColor.darkGray
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
#objc
func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}
Your page View
struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
#State var currentPage = 0
init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
var body: some View {
ZStack(alignment: .bottom) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
}
}
}
Your page View Controller
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
#Binding var currentPage: Int
#State private var previousPage = 0
init(controllers: [UIViewController],
currentPage: Binding<Int>)
{
self.controllers = controllers
self._currentPage = currentPage
self.previousPage = currentPage.wrappedValue
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
guard !controllers.isEmpty else {
return
}
let direction: UIPageViewController.NavigationDirection = previousPage < currentPage ? .forward : .reverse
context.coordinator.parent = self
pageViewController.setViewControllers(
[controllers[currentPage]], direction: direction, animated: true) { _ in {
previousPage = currentPage
}
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
Let's say you have a view like
struct CardView: View {
var album: Album
var body: some View {
URLImage(URL(string: album.albumArtWork)!)
.resizable()
.aspectRatio(3 / 2, contentMode: .fit)
}
}
You can use this component in your main SwiftUI view like this.
PageView(vM.Albums.map { CardView(album: $0) }).frame(height: 250)
iOS 13+ (private API)
Warning: The following answer uses private SwiftUI methods that aren't publicly visible (you can still access them if you know where to look). However, they are not documented properly and may be unstable. Use them at your own risk.
While browsing SwiftUI files I stumbled upon the _PagingView that seems to be available since iOS 13:
#available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct _PagingView<Views> : SwiftUI.View where Views : Swift.RandomAccessCollection, Views.Element : SwiftUI.View, Views.Index : Swift.Hashable
This view has two initialisers:
public init(config: SwiftUI._PagingViewConfig = _PagingViewConfig(), page: SwiftUI.Binding<Views.Index>? = nil, views: Views)
public init(direction: SwiftUI._PagingViewConfig.Direction, page: SwiftUI.Binding<Views.Index>? = nil, views: Views)
What we also have is the _PagingViewConfig:
#available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct _PagingViewConfig : Swift.Equatable {
public enum Direction {
case vertical
case horizontal
public static func == (a: SwiftUI._PagingViewConfig.Direction, b: SwiftUI._PagingViewConfig.Direction) -> Swift.Bool
public var hashValue: Swift.Int {
get
}
public func hash(into hasher: inout Swift.Hasher)
}
public var direction: SwiftUI._PagingViewConfig.Direction
public var size: CoreGraphics.CGFloat?
public var margin: CoreGraphics.CGFloat
public var spacing: CoreGraphics.CGFloat
public var constrainedDeceleration: Swift.Bool
public init(direction: SwiftUI._PagingViewConfig.Direction = .horizontal, size: CoreGraphics.CGFloat? = nil, margin: CoreGraphics.CGFloat = 0, spacing: CoreGraphics.CGFloat = 0, constrainedDeceleration: Swift.Bool = true)
public static func == (a: SwiftUI._PagingViewConfig, b: SwiftUI._PagingViewConfig) -> Swift.Bool
}
Now, we can create a simple _PagingView:
_PagingView(direction: .horizontal, views: [
AnyView(Color.red),
AnyView(Text("Hello world")),
AnyView(Rectangle().frame(width: 100, height: 100))
])
Here is another, more customised example:
struct ContentView: View {
#State private var selection = 1
var body: some View {
_PagingView(
config: _PagingViewConfig(
direction: .vertical,
size: nil,
margin: 10,
spacing: 10,
constrainedDeceleration: false
),
page: $selection,
views: [
AnyView(Color.red),
AnyView(Text("Hello world")),
AnyView(Rectangle().frame(width: 100, height: 100))
]
)
}
}
For apps that target iOS 14 and later, the answer suggested by #pawello2222 should be considered the correct one. I have tried it in two apps now and it works great, with very little code.
I have wrapped the proposed concept in a struct that can be provided with both views as well as with an item list and a view builder. It can be found here. The code looks like this:
#available(iOS 14.0, *)
public struct MultiPageView: View {
public init<PageType: View>(
pages: [PageType],
indexDisplayMode: PageTabViewStyle.IndexDisplayMode = .automatic,
currentPageIndex: Binding<Int>) {
self.pages = pages.map { AnyView($0) }
self.indexDisplayMode = indexDisplayMode
self.currentPageIndex = currentPageIndex
}
public init<Model, ViewType: View>(
items: [Model],
indexDisplayMode: PageTabViewStyle.IndexDisplayMode = .automatic,
currentPageIndex: Binding<Int>,
pageBuilder: (Model) -> ViewType) {
self.pages = items.map { AnyView(pageBuilder($0)) }
self.indexDisplayMode = indexDisplayMode
self.currentPageIndex = currentPageIndex
}
private let pages: [AnyView]
private let indexDisplayMode: PageTabViewStyle.IndexDisplayMode
private var currentPageIndex: Binding<Int>
public var body: some View {
TabView(selection: currentPageIndex) {
ForEach(Array(pages.enumerated()), id: \.offset) {
$0.element.tag($0.offset)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: indexDisplayMode))
}
}
first you adds the package https://github.com/xmartlabs/PagerTabStripView
then
import SwiftUI
import PagerTabStripView
struct MyPagerView: View {
var body: some View {
PagerTabStripView() {
FirstView()
.frame(width: UIScreen.main.bounds.width)
.pagerTabItem {
TitleNavBarItem(title: "ACCOUNT", systomIcon: "character.bubble.fill")
}
ContentView()
.frame(width: UIScreen.main.bounds.width)
.pagerTabItem {
TitleNavBarItem(title: "PROFILE", systomIcon: "person.circle.fill")
}
NewsAPIView()
.frame(width: UIScreen.main.bounds.width)
.pagerTabItem {
TitleNavBarItem(title: "PASSWORD", systomIcon: "lock.fill")
}
}
.pagerTabStripViewStyle(.barButton(indicatorBarHeight: 4, indicatorBarColor: .black, tabItemSpacing: 0, tabItemHeight: 90))
}
}
struct TitleNavBarItem: View {
let title: String
let systomIcon: String
var body: some View {
VStack {
Image(systemName: systomIcon)
.foregroundColor( .white)
.font(.title)
Text( title)
.font(.system(size: 22))
.bold()
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.orange)
}
}
The easiest way to do this is via iPages.
import SwiftUI
import iPages
struct ContentView: View {
#State var currentPage = 0
var body: some View {
iPages(currentPage: $currentPage) {
Text("😋")
Color.pink
}
}
}
I'm trying to set a certain size for a popover or to make it adapt its content
I tried to change the frame for the view from popover, but it does not seem to work
Button("Popover") {
self.popover7.toggle()
}.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
PopoverView().frame(width: 100, height: 100, alignment: .center)
}
I'd like to achieve this behaviour I found in Calendar app in iPad
The solution by #ccwasden works very well. I extended his work by making it more "natural" in terms of SwiftUI. Also, this version utilizes sizeThatFits method, so you don't have to specify the size of the popover content.
struct PopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: () -> PopoverContent
func body(content: Content) -> some View {
content
.background(
Popover(
isPresented: self.$isPresented,
onDismiss: self.onDismiss,
content: self.content
)
)
}
}
extension View {
func popover<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: #escaping () -> Content
) -> some View where Content: View {
ModifiedContent(
content: self,
modifier: PopoverViewModifier(
isPresented: isPresented,
onDismiss: onDismiss,
content: content
)
)
}
}
struct Popover<Content: View> : UIViewControllerRepresentable {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
#ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, content: self.content())
}
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
context.coordinator.host.rootView = self.content()
if self.isPresented, uiViewController.presentedViewController == nil {
let host = context.coordinator.host
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: Int.max, height: Int.max))
host.modalPresentationStyle = UIModalPresentationStyle.popover
host.popoverPresentationController?.delegate = context.coordinator
host.popoverPresentationController?.sourceView = uiViewController.view
host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
uiViewController.present(host, animated: true, completion: nil)
}
}
class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
let host: UIHostingController<Content>
private let parent: Popover
init(parent: Popover, content: Content) {
self.parent = parent
self.host = UIHostingController(rootView: content)
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.parent.isPresented = false
if let onDismiss = self.parent.onDismiss {
onDismiss()
}
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}
}
I got it to work on iOS with a custom UIViewRepresentable. Here is what the usage looks like:
struct Content: View {
#State var open = false
#State var popoverSize = CGSize(width: 300, height: 300)
var body: some View {
WithPopover(
showPopover: $open,
popoverSize: popoverSize,
content: {
Button(action: { self.open.toggle() }) {
Text("Tap me")
}
},
popoverContent: {
VStack {
Button(action: { self.popoverSize = CGSize(width: 300, height: 600)}) {
Text("Increase size")
}
Button(action: { self.open = false}) {
Text("Close")
}
}
})
}
}
And here is a gist with the source for WithPopover
macOS-only
Here is how to change frame of popover dynamically... for simplicity it is w/o animation, it is up to you.
struct TestCustomSizePopover: View {
#State var popover7 = false
var body: some View {
VStack {
Button("Popover") {
self.popover7.toggle()
}.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
PopoverView()
}
}.frame(width: 800, height: 600)
}
}
struct PopoverView: View {
#State var adaptableHeight = CGFloat(100)
var body: some View {
VStack {
Text("Popover").padding()
Button(action: {
self.adaptableHeight = 300
}) {
Text("Button")
}
}
.frame(width: 100, height: adaptableHeight)
}
}
So when I make a list in SwiftUI, I get the master-detail split view for "free".
So for instance with this:
import SwiftUI
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: Text("Hello!")) {
Text(person)
}
}
}
Text("🤪")
}
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
I get a splitView if an iPad simulator is in landscape, and the first detail screen is the emoji. But if people tap on a name, the detail view is "Hello!"
All that is great.
However, if I run the iPad in portrait, the user is greeted by the emoji, and then there is no indication that there is a list. You have to swipe from left to right to make the list appear from the side.
Does anyone know of a way to get even a navigation bar to appear that would let the user tap to see the list of items on the left? So that it's not a screen with the emoji only?
I would hate to leave a note that says "Swipe in from the left to see the list of files/people/whatever"
I remember UISplitViewController had a collapsed property that could be set. Is there anything like that here?
In Xcode 11 beta 3, Apple has added .navigationViewStyle(style:) to NavigationView.
Updated for Xcode 11 Beta 5.
create MasterView() & DetailsView().
struct MyMasterView: View {
var people = ["Angela", "Juan", "Yeji"]
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)
}
}
inside my ContentView :
var body: some View {
NavigationView {
MyMasterView()
DetailsView()
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding()
}
Output:
For now, in Xcode 11.2.1 it is still nothing changed.
I had the same issue with SplitView on iPad and resolved it by adding padding like in Ketan Odedra response, but modified it a little:
var body: some View {
GeometryReader { geometry in
NavigationView {
MasterView()
DetailsView()
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding(.leading, leadingPadding(geometry))
}
}
private func leadingPadding(_ geometry: GeometryProxy) -> CGFloat {
if UIDevice.current.userInterfaceIdiom == .pad {
return 0.5
}
return 0
}
This works perfectly in the simulator. But when I submit my app for review, it was rejected. This little hack doesn't work on the reviewer device. I don't have a real iPad, so I don't know what caused this. Try it, maybe it will work for you.
While it doesn't work for me, I requested help from Apple DTS.
They respond to me that for now, SwiftUI API can't fully simulate UIKit`s SplitViewController behavior. But there is a workaround.
You can create custom SplitView in SwiftUI:
struct SplitView<Master: View, Detail: View>: View {
var master: Master
var detail: Detail
init(#ViewBuilder master: () -> Master, #ViewBuilder detail: () -> Detail) {
self.master = master()
self.detail = detail()
}
var body: some View {
let viewControllers = [UIHostingController(rootView: master), UIHostingController(rootView: detail)]
return SplitViewController(viewControllers: viewControllers)
}
}
struct SplitViewController: UIViewControllerRepresentable {
var viewControllers: [UIViewController]
#Environment(\.splitViewPreferredDisplayMode) var preferredDisplayMode: UISplitViewController.DisplayMode
func makeUIViewController(context: Context) -> UISplitViewController {
return UISplitViewController()
}
func updateUIViewController(_ splitController: UISplitViewController, context: Context) {
splitController.preferredDisplayMode = preferredDisplayMode
splitController.viewControllers = viewControllers
}
}
struct PreferredDisplayModeKey : EnvironmentKey {
static var defaultValue: UISplitViewController.DisplayMode = .automatic
}
extension EnvironmentValues {
var splitViewPreferredDisplayMode: UISplitViewController.DisplayMode {
get { self[PreferredDisplayModeKey.self] }
set { self[PreferredDisplayModeKey.self] = newValue }
}
}
extension View {
/// Sets the preferred display mode for SplitView within the environment of self.
func splitViewPreferredDisplayMode(_ mode: UISplitViewController.DisplayMode) -> some View {
self.environment(\.splitViewPreferredDisplayMode, mode)
}
}
And then use it:
SplitView(master: {
MasterView()
}, detail: {
DetailView()
}).splitViewPreferredDisplayMode(.allVisible)
On an iPad, it works. But there is one issue (maybe more..).
This approach ruins navigation on iPhone because both MasterView and DetailView have their NavigationView.
UPDATE:
Finally, in Xcode 11.4 beta 2 they added a button in Navigation Bar that indicates hidden master view.
Minimal testing in the Simulator, but this should be close to a real solution. The idea is to use an EnvironmentObject to hold a published var on whether to use a double column NavigationStyle, or a single one, then have the NavigationView get recreated if that var changes.
The EnvironmentObject:
final class AppEnvironment: ObservableObject {
#Published var useSideBySide: Bool = false
}
In the Scene Delegate, set the variable at launch, then observe device rotations and possibly change it (the "1000" is not the correct value, starting point):
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var appEnvironment = AppEnvironment()
#objc
func orientationChanged() {
let bounds = UIScreen.main.nativeBounds
let orientation = UIDevice.current.orientation
// 1000 is a starting point, should be smallest height of a + size iPhone
if orientation.isLandscape && bounds.size.height > 1000 {
if appEnvironment.useSideBySide == false {
appEnvironment.useSideBySide = true
print("SIDE changed to TRUE")
}
} else if orientation.isPortrait && appEnvironment.useSideBySide == true {
print("SIDE changed to false")
appEnvironment.useSideBySide = false
}
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(appEnvironment))
self.window = window
window.makeKeyAndVisible()
orientationChanged()
NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil)
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
}
In the top level content view, where the NavigationView is created, use a custom modifier instead of using a navigationViewStyle directly:
struct ContentView: View {
#State private var dates = [Date]()
var body: some View {
NavigationView {
MV(dates: $dates)
DetailView()
}
.modifier( WTF() )
}
struct WTF: ViewModifier {
#EnvironmentObject var appEnvironment: AppEnvironment
func body(content: Content) -> some View {
Group {
if appEnvironment.useSideBySide == true {
content
.navigationViewStyle(DoubleColumnNavigationViewStyle())
} else {
content
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
}
}
As mentioned earlier, just Simulator testing, but I tried launching in both orientations, rotating with Master showing, rotating with Detail showing, it all looks good to me.
For current version (iOS 13.0-13.3.x), you can use my code.
I use a UIViewUpdater to access the underlaying UIView and its UIViewController to adjust the bar item.
I think the UIViewUpdater way to solve this problem is the most Swifty and robust way, and you can use it to access and modify other UIView, UIViewController related UIKit mechanism.
ContentView.swift
import SwiftUI
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: DetailView()) { Text(person) }
}
}
InitialDetailView()
}
}
}
struct DetailView : View {
var body: some View {
Text("Hello!")
}
}
struct InitialDetailView : View {
var body: some View {
NavigationView {
Text("🤪")
.navigationBarTitle("", displayMode: .inline) // .inline is neccesary for showing the left button item
.updateUIViewController {
$0.splitViewController?.preferredDisplayMode = .primaryOverlay // for showing overlay at initial
$0.splitViewController?.preferredDisplayMode = .automatic
}
.displayModeButtonItem()
}.navigationViewStyle(StackNavigationViewStyle())
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
Utility code for the solution. Put it in any Swift file for the project.
Utility.swift
// View decoration
public extension View {
func updateUIView(_ action: #escaping (UIView) -> Void) -> some View {
background(UIViewUpdater(action: action).opacity(0))
}
func updateUIViewController(_ action: #escaping (UIViewController) -> Void) -> some View {
updateUIView {
guard let viewController = $0.viewController else { return }
action(viewController)
}
}
func displayModeButtonItem(_ position: NavigationBarPostion = .left) -> some View {
updateUIViewController { $0.setDisplayModeButtonItem(position) }
}
}
// UpdateUIView
struct UIViewUpdater : UIViewRepresentable {
let action: (UIView) -> Void
typealias UIViewType = InnerUIView
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIViewType {
UIViewType(action: action)
}
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Self>) {
// DispatchQueue.main.async { [action] in action(uiView) }
}
class InnerUIView : UIView {
let action: (UIView) -> Void
init(action: #escaping (UIView) -> Void) {
self.action = action
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToWindow() {
super.didMoveToWindow()
update()
}
func update() {
action(self)
}
}
}
// UIView.viewController
public extension UIView {
var viewController: UIViewController? {
var i: UIResponder? = self
while i != nil {
if let vc = i as? UIViewController { return vc }
i = i?.next
}
return nil
}
}
// UIViewController.setDisplayModeButtonItem
public enum NavigationBarPostion {
case left
case right
}
public extension UIViewController {
func setDisplayModeButtonItem(_ position: NavigationBarPostion) {
guard let splitViewController = splitViewController else { return }
switch position {
case .left:
// keep safe to avoid replacing other left bar button item, e.g. navigation back
navigationItem.leftItemsSupplementBackButton = true
navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
case .right:
navigationItem.rightBarButtonItem = splitViewController.displayModeButtonItem
}
}
}
import SwiftUI
var hostingController: UIViewController?
func showList() {
let split = hostingController?.children[0] as? UISplitViewController
UIView.animate(withDuration: 0.3, animations: {
split?.preferredDisplayMode = .primaryOverlay
}) { _ in
split?.preferredDisplayMode = .automatic
}
}
func hideList() {
let split = hostingController?.children[0] as? UISplitViewController
split?.preferredDisplayMode = .primaryHidden
}
// =====
struct Dest: View {
var person: String
var body: some View {
VStack {
Text("Hello! \(person)")
Button(action: showList) {
Image(systemName: "sidebar.left")
}
}
.onAppear(perform: hideList)
}
}
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: Dest(person: person)) {
Text(person)
}
}
}
VStack {
Text("🤪")
Button(action: showList) {
Image(systemName: "sidebar.left")
}
}
}
}
}
import PlaygroundSupport
hostingController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.setLiveView(hostingController!)
At WWDC 2019, Apple announced a new "card-style" look for modal presentations, which brought along with it built-in gestures for dismissing modal view controllers by swiping down on the card. They also introduced the new isModalInPresentation property on UIViewController so that you can disallow this dismissal behavior if you so choose.
So far, though, I have found no way to emulate this behavior in SwiftUI. Using the .presentation(_ modal: Modal?), does not, as far as I can tell, allow you to disable the dismissal gestures in the same way. I also attempted putting the modal view controller inside a UIViewControllerRepresentable View, but that didn't seem to help either:
struct MyViewControllerView: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<MyViewControllerView>) -> UIHostingController<MyView> {
return UIHostingController(rootView: MyView())
}
func updateUIViewController(_ uiViewController: UIHostingController<MyView>, context: UIViewControllerRepresentableContext<MyViewControllerView>) {
uiViewController.isModalInPresentation = true
}
}
Even after presenting with .presentation(Modal(MyViewControllerView())) I was able to swipe down to dismiss the view. Is there currently any way to do this with existing SwiftUI constructs?
Update for iOS 15
As per pawello2222's answer below, this is now supported by the new interactiveDismissDisabled(_:) API.
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Text("Content View")
.sheet(isPresented: $showSheet) {
Text("Sheet View")
.interactiveDismissDisabled(true)
}
}
}
Pre-iOS-15 answer
I wanted to do this as well, but couldn't find the solution anywhere. The answer that hijacks the drag gesture kinda works, but not when it's dismissed by scrolling a scroll view or form. The approach in the question is less hacky also, so I investigated it further.
For my use case I have a form in a sheet which ideally could be dismissed when there's no content, but has to be confirmed through a alert when there is content.
My solution for this problem:
struct ModalSheetTest: View {
#State private var showModally = false
#State private var showSheet = false
var body: some View {
Form {
Toggle(isOn: self.$showModally) {
Text("Modal")
}
Button(action: { self.showSheet = true}) {
Text("Show sheet")
}
}
.sheet(isPresented: $showSheet) {
Form {
Button(action: { self.showSheet = false }) {
Text("Hide me")
}
}
.presentation(isModal: self.showModally) {
print("Attempted to dismiss")
}
}
}
}
The state value showModally determines if it has to be showed modally. If so, dragging it down to dismiss will only trigger the closure which just prints "Attempted to dismiss" in the example, but can be used to show the alert to confirm dismissal.
struct ModalView<T: View>: UIViewControllerRepresentable {
let view: T
let isModal: Bool
let onDismissalAttempt: (()->())?
func makeUIViewController(context: Context) -> UIHostingController<T> {
UIHostingController(rootView: view)
}
func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
context.coordinator.modalView = self
uiViewController.rootView = view
uiViewController.parent?.presentationController?.delegate = context.coordinator
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
let modalView: ModalView
init(_ modalView: ModalView) {
self.modalView = modalView
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
!modalView.isModal
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
modalView.onDismissalAttempt?()
}
}
}
extension View {
func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
}
}
This is perfect for my use case, hope it helps you or someone else out as well.
By changing the gesture priority of any view you don't want to be dragged, you can prevent DragGesture on any view. For example for Modal it can be done as bellow:
Maybe it is not a best practice, but it works perfectly
struct ContentView: View {
#State var showModal = true
var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}.sheet(isPresented: self.$showModal) {
ModalView()
}
}
}
struct ModalView : View {
#Environment(\.presentationMode) var presentationMode
let dg = DragGesture()
var body: some View {
ZStack {
Rectangle()
.fill(Color.white)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.highPriorityGesture(dg)
Button("Dismiss Modal") {
self.presentationMode.wrappedValue.dismiss()
}
}
}
}
Note: This code has been edited for clarity and brevity.
Using a way to get the current window scene from here you can get the top view controller by this extension here from #Bobj-C
extension UIApplication {
func visibleViewController() -> UIViewController? {
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
guard let rootViewController = window.rootViewController else { return nil }
return UIApplication.getVisibleViewControllerFrom(vc: rootViewController)
}
private static func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
if let navigationController = vc as? UINavigationController,
let visibleController = navigationController.visibleViewController {
return UIApplication.getVisibleViewControllerFrom( vc: visibleController )
} else if let tabBarController = vc as? UITabBarController,
let selectedTabController = tabBarController.selectedViewController {
return UIApplication.getVisibleViewControllerFrom(vc: selectedTabController )
} else {
if let presentedViewController = vc.presentedViewController {
return UIApplication.getVisibleViewControllerFrom(vc: presentedViewController)
} else {
return vc
}
}
}
}
and turn it into a view modifier like this:
struct DisableModalDismiss: ViewModifier {
let disabled: Bool
func body(content: Content) -> some View {
disableModalDismiss()
return AnyView(content)
}
func disableModalDismiss() {
guard let visibleController = UIApplication.shared.visibleViewController() else { return }
visibleController.isModalInPresentation = disabled
}
}
and use like:
struct ShowSheetView: View {
#State private var showSheet = true
var body: some View {
Text("Hello, World!")
.sheet(isPresented: $showSheet) {
TestView()
.modifier(DisableModalDismiss(disabled: true))
}
}
}
For everyone who has problems with #Guido's solution and NavigationView. Just combine the solution of #Guido and #SlimeBaron
class ModalHostingController<Content: View>: UIHostingController<Content>, UIAdaptivePresentationControllerDelegate {
var canDismissSheet = true
var onDismissalAttempt: (() -> ())?
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.presentationController?.delegate = self
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
canDismissSheet
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
onDismissalAttempt?()
}
}
struct ModalView<T: View>: UIViewControllerRepresentable {
let view: T
let canDismissSheet: Bool
let onDismissalAttempt: (() -> ())?
func makeUIViewController(context: Context) -> ModalHostingController<T> {
let controller = ModalHostingController(rootView: view)
controller.canDismissSheet = canDismissSheet
controller.onDismissalAttempt = onDismissalAttempt
return controller
}
func updateUIViewController(_ uiViewController: ModalHostingController<T>, context: Context) {
uiViewController.rootView = view
uiViewController.canDismissSheet = canDismissSheet
uiViewController.onDismissalAttempt = onDismissalAttempt
}
}
extension View {
func interactiveDismiss(canDismissSheet: Bool, onDismissalAttempt: (() -> ())? = nil) -> some View {
ModalView(
view: self,
canDismissSheet: canDismissSheet,
onDismissalAttempt: onDismissalAttempt
).edgesIgnoringSafeArea(.all)
}
}
Usage:
struct ContentView: View {
#State var isPresented = false
#State var canDismissSheet = false
var body: some View {
Button("Tap me") {
isPresented = true
}
.sheet(
isPresented: $isPresented,
content: {
NavigationView {
Text("Hello World")
}
.interactiveDismiss(canDismissSheet: canDismissSheet) {
print("attemptToDismissHandler")
}
}
)
}
}
As of iOS 14, you can use .fullScreenCover(isPresented:, content:) (Docs) instead of .sheet(isPresented:, content:) if you don't want the dismissal gestures.
struct FullScreenCoverPresenterView: View {
#State private var isPresenting = false
var body: some View {
Button("Present Full-Screen Cover") {
isPresenting.toggle()
}
.fullScreenCover(isPresented: $isPresenting) {
Text("Tap to Dismiss")
.onTapGesture {
isPresenting.toggle()
}
}
}
}
Note: fullScreenCover is unavailable on macOS, but it works well on iPhone and iPad.
Note: This solution doesn't allow you to enable the dismissal gesture when a certain condition is met. To enable and disable the dismissal gesture with a condition, see my other answer.
iOS 15+
Starting from iOS 15 we can use interactiveDismissDisabled:
func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View
We just need to attach it to the sheet:
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Text("Content View")
.sheet(isPresented: $showSheet) {
Text("Sheet View")
.interactiveDismissDisabled(true)
}
}
}
If needed, you can also pass a variable to control when the sheet can be disabled:
.interactiveDismissDisabled(!userAcceptedTermsOfUse)
You can use this method to pass the content of the modal view for reuse.
Use NavigationView with gesture priority to disable dragging.
import SwiftUI
struct ModalView<Content: View>: View
{
#Environment(\.presentationMode) var presentationMode
let content: Content
let title: String
let dg = DragGesture()
init(title: String, #ViewBuilder content: #escaping () -> Content) {
self.content = content()
self.title = title
}
var body: some View
{
NavigationView
{
ZStack (alignment: .top)
{
self.content
}
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: {
ToolbarItem(placement: .principal, content: {
Text(title)
})
ToolbarItem(placement: .navigationBarTrailing, content: {
Button("Done") {
self.presentationMode.wrappedValue.dismiss()
}
})
})
}
.highPriorityGesture(dg)
}
}
In Content View:
struct ContentView: View {
#State var showModal = true
var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}.sheet(isPresented: self.$showModal) {
ModalView (title: "Title") {
Text("Prevent dismissal of modal view.")
}
}
}
}
Result!
We have created an extension to make controlling the modal dismission effortless, at https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0
/// Example:
struct ContentView: View {
#State private var presenting = false
var body: some View {
VStack {
Button {
presenting = true
} label: {
Text("Present")
}
}
.sheet(isPresented: $presenting) {
ModalContent()
.allowAutoDismiss { false }
// or
// .allowAutoDismiss(false)
}
}
}
This solution worked for me on iPhone and iPad. It uses isModalInPresentation. From the docs:
The default value of this property is false. When you set it to true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.
Your attempt is close to what worked for me. The trick is setting isModalInPresentation on the hosting controller's parent in willMove(toParent:)
class MyHostingController<Content: View>: UIHostingController<Content> {
var canDismissSheet = true
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.isModalInPresentation = !canDismissSheet
}
}
struct MyViewControllerView<Content: View>: UIViewControllerRepresentable {
let content: Content
let canDismissSheet: Bool
func makeUIViewController(context: Context) -> UIHostingController<Content> {
let viewController = MyHostingController(rootView: content)
viewController.canDismissSheet = canDismissSheet
return viewController
}
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
uiViewController.parent?.isModalInPresentation = !canDismissSheet
}
}
it supports most of the iOS version
no need of making wrappers just do this
extension UINavigationController {
open override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.isEnabled = false
interactivePopGestureRecognizer?.delegate = nil
}}