SwiftUI - 半模态?

93

我正在尝试在SwiftUI中重新创建类似于iOS13 Safari的Modal:

它看起来像这样:

图片描述

有没有人知道是否可以在SwiftUI中实现这个效果?我想展示一个小的半模态视图,并且可以拖拽到全屏显示,就像共享菜单一样。

非常感谢任何建议!


难道不只是overlay吗? - user28434'mstep
1
我不太确定,我认为这可能是模态框或弹出窗口上的一个选项,但目前文档相当稀少。 - ryannn
1
我发现 SwiftUI 中的模态选项的选项太少了。例如,我找不到一种方法来选择其演示样式为“FormSheet”。这是自 iOS 3.2 以来就存在的非常基本的功能!我不得不使用 UIHostingController 的技巧。 - kontiki
6
使用纯SwiftUI编写。享受吧! https://github.com/cyrilzakka/SwiftUIModal。实现全屏和半屏模态窗口功能。 - cyril
3
@ryannn 看起来 iOS 16 终于支持半页了 - 详情请参见此答案 - pawello2222
显示剩余2条评论
14个回答

38
在Swift 5.5 iOS 15+和Mac Catalyst 15+中,有一个新的解决方案,称为adaptiveSheetPresentationController。详情请参见https://developer.apple.com/documentation/uikit/uipopoverpresentationcontroller/3810055-adaptivesheetpresentationcontrol?changes=__4
@available(iOS 15.0, *)
struct CustomSheetParentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack{
            Button("present sheet", action: {
                isPresented.toggle()
            }).adaptiveSheet(isPresented: $isPresented, detents: [.medium()], smallestUndimmedDetentIdentifier: .large){
                Rectangle()
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                    .foregroundColor(.clear)
                    .border(Color.blue, width: 3)
                    .overlay(Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: .infinity)
                                .onTapGesture {
                        isPresented.toggle()
                    }
                    )
            }
            
        }
    }
}
@available(iOS 15.0, *)
struct AdaptiveSheet<T: View>: ViewModifier {
    let sheetContent: T
    @Binding var isPresented: Bool
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    
    init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T) {
        self.sheetContent = content()
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        self._isPresented = isPresented
    }
    func body(content: Content) -> some View {
        ZStack{
            content
            CustomSheet_UI(isPresented: $isPresented, detents: detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {sheetContent}).frame(width: 0, height: 0)
        }
    }
}
@available(iOS 15.0, *)
extension View {
    func adaptiveSheet<T: View>(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T)-> some View {
        modifier(AdaptiveSheet(isPresented: isPresented, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: content))
    }
}

@available(iOS 15.0, *)
struct CustomSheet_UI<Content: View>: UIViewControllerRepresentable {
    
    let content: Content
    @Binding var isPresented: Bool
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    
    init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        self._isPresented = isPresented
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    func makeUIViewController(context: Context) -> CustomSheetViewController<Content> {
        let vc = CustomSheetViewController(coordinator: context.coordinator, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge:  prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {content})
        return vc
    }
    
    func updateUIViewController(_ uiViewController: CustomSheetViewController<Content>, context: Context) {
        if isPresented{
            uiViewController.presentModalView()
        }else{
            uiViewController.dismissModalView()
        }
    }
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        var parent: CustomSheet_UI
        init(_ parent: CustomSheet_UI) {
            self.parent = parent
        }
        //Adjust the variable when the user dismisses with a swipe
        func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
            if parent.isPresented{
                parent.isPresented = false
            }
            
        }
        
    }
}

@available(iOS 15.0, *)
class CustomSheetViewController<Content: View>: UIViewController {
    let content: Content
    let coordinator: CustomSheet_UI<Content>.Coordinator
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
    init(coordinator: CustomSheet_UI<Content>.Coordinator, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.coordinator = coordinator
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        super.init(nibName: nil, bundle: .main)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func dismissModalView(){
        dismiss(animated: true, completion: nil)
    }
    func presentModalView(){
        
        let hostingController = UIHostingController(rootView: content)
        
        hostingController.modalPresentationStyle = .popover
        hostingController.presentationController?.delegate = coordinator as UIAdaptivePresentationControllerDelegate
        hostingController.modalTransitionStyle = .coverVertical
        if let hostPopover = hostingController.popoverPresentationController {
            hostPopover.sourceView = super.view
            let sheet = hostPopover.adaptiveSheetPresentationController
            //As of 13 Beta 4 if .medium() is the only detent in landscape error occurs
            sheet.detents = (isLandscape ? [.large()] : detents)
            sheet.largestUndimmedDetentIdentifier =
            smallestUndimmedDetentIdentifier
            sheet.prefersScrollingExpandsWhenScrolledToEdge =
            prefersScrollingExpandsWhenScrolledToEdge
            sheet.prefersEdgeAttachedInCompactHeight =
            prefersEdgeAttachedInCompactHeight
            sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
            
        }
        if presentedViewController == nil{
            present(hostingController, animated: true, completion: nil)
        }
    }
    /// To compensate for orientation as of 13 Beta 4 only [.large()] works for landscape
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        if UIDevice.current.orientation.isLandscape {
            isLandscape = true
            self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.large()]
        } else {
            isLandscape = false
            self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = detents
        }
    }
}

@available(iOS 15.0, *)
struct CustomSheetView_Previews: PreviewProvider {
    static var previews: some View {
        CustomSheetParentView()
    }
}

iOS 16 Beta

iOS 16 Beta中,苹果公司提供了一个纯SwiftUI的Half-Modal解决方案。

    .sheet(isPresented: $showSettings) {
        SettingsView()
            .presentationDetents:(
                [.medium, .large],
                selection: $settingsDetent
             )
    }

您还可以添加自定义的刻度,并指定百分比。
static func custom<D>(D.Type) -> PresentationDetent
//A custom detent with a calculated height.
static func fraction(CGFloat) -> PresentationDetent
//A custom detent with the specified fractional height.
static func height(CGFloat) -> PresentationDetent
//A custom detent with the specified height.

例子:

extension PresentationDetent {
    static let bar = Self.fraction(0.2)
}

.sheet(isPresented: $showSettings) {
    SettingsView()
        .presentationDetents:([.bar])
}

1
@Sverrisson NP。我喜欢这个反馈。我添加了一个解决方法。现在如果是横屏,它将默认为.large()。我会提交一个错误报告。如果其他人也提交一个错误报告可能会有所帮助。 - lorem ipsum
1
@BjørnOlavJalborg,你需要做类似于这样的事情,在自定义方法中,你需要像在viewWillTransition中更改变量一样进行更改。 - lorem ipsum
1
@BjørnOlavJalborg,你在sheet上有相同的行为吗?我正在尝试在我的端口复制。如果问题是我认为的那样,苹果使用带有单位项目的表格来克服它。如果是这样,请告诉我,我想我有一个解决方案。 - lorem ipsum
1
@BjørnOlavJalborg 尝试将 switch 放在一个带有 @Binding var content: String 变量的子视图中。@Binding 将触发更改。 - lorem ipsum
3
你掉了这个。它完美地工作。非常感谢你。 - Bjørn Olav Jalborg
显示剩余13条评论

30

iOS 16+

看起来在iOS 16中终于支持了半弹窗。

我们可以使用 PresentationDetent,具体地使用 presentationDetents(_:selection:) 来管理弹窗的大小。

这里是一个示例

struct ContentView: View {
    @State private var showSettings = false
    @State private var settingsDetent = PresentationDetent.medium

    var body: some View {
        Button("View Settings") {
            showSettings = true
        }
        .sheet(isPresented: $showSettings) {
            SettingsView()
                .presentationDetents(
                    [.medium, .large],
                    selection: $settingsDetent
                 )
        }
    }
}

请注意,如果您提供多个挡板(dentent),人们可以拖动表格来调整其大小

下面是PresentationDetent的可能值:

  • large
  • medium
  • fraction(CGFloat)
  • height(CGFloat)
  • custom<D>(D.Type)

6
有效答案需要3年时间,但仍处于测试阶段,哈哈。做得好,苹果。 - Quang Hà
2
听起来很完美。有人创建了一个框架来将类似的功能移植到iOS 15吗? - Kudit

24

1
为什么不添加一个 .podspec 文件呢? - orkenstein
4
我不明白为什么需要修改 SceneDelegate... 我的应用是使用了 SwiftUI 的 UIKit 应用程序。是否有一种简单的方法可以将此组件与特定的 SwiftUI 视图一起使用? - Steve Macdonald
简单易用,运行非常稳定。:+1: - Li Jin
@orkenstein 为什么你需要一个 .podspec 文件来管理 Swift 包?这个想法是避免使用 pod install 命令。 - CDM social medias in bio
对于那些说“为什么要使用podspec?”的人来说,因为你的项目并不是地球上唯一的一个。 - tsalaroth
显示剩余2条评论

16

你可以自己制作并放置在 zstack 中: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks

struct SlideOverCard<Content: View> : View {
    @GestureState private var dragState = DragState.inactive
    @State var position = CardPosition.top

    var content: () -> Content
    var body: some View {
        let drag = DragGesture()
            .updating($dragState) { drag, state, transaction in
                state = .dragging(translation: drag.translation)
            }
            .onEnded(onDragEnded)

        return Group {
            Handle()
            self.content()
        }
        .frame(height: UIScreen.main.bounds.height)
        .background(Color.white)
        .cornerRadius(10.0)
        .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
        .offset(y: self.position.rawValue + self.dragState.translation.height)
        .animation(self.dragState.isDragging ? nil : .spring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
        .gesture(drag)
    }

    private func onDragEnded(drag: DragGesture.Value) {
        let verticalDirection = drag.predictedEndLocation.y - drag.location.y
        let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
        let positionAbove: CardPosition
        let positionBelow: CardPosition
        let closestPosition: CardPosition

        if cardTopEdgeLocation <= CardPosition.middle.rawValue {
            positionAbove = .top
            positionBelow = .middle
        } else {
            positionAbove = .middle
            positionBelow = .bottom
        }

        if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
            closestPosition = positionAbove
        } else {
            closestPosition = positionBelow
        }

        if verticalDirection > 0 {
            self.position = positionBelow
        } else if verticalDirection < 0 {
            self.position = positionAbove
        } else {
            self.position = closestPosition
        }
    }
}

enum CardPosition: CGFloat {
    case top = 100
    case middle = 500
    case bottom = 850
}

enum DragState {
    case inactive
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}

3
Handle未定义。 - Lucas C. Feijo
1
如果您点击链接,就会是这样。 - Aecasorg

10

这是我的天真的底部表格,可以根据其内容进行缩放。没有拖动功能,但如果需要的话添加起来应该相对容易 :)

struct BottomSheet<SheetContent: View>: ViewModifier {
    @Binding var isPresented: Bool
    let sheetContent: () -> SheetContent

    func body(content: Content) -> some View {
        ZStack {
            content
        
            if isPresented {
                VStack {
                    Spacer()
                
                    VStack {
                        HStack {
                            Spacer()
                            Button(action: {
                                withAnimation(.easeInOut) {
                                    self.isPresented = false
                                }
                            }) {
                                Text("done")
                                    .padding(.top, 5)
                            }
                        }
                    
                        sheetContent()
                    }
                    .padding()
                }
                .zIndex(.infinity)
                .transition(.move(edge: .bottom))
                .edgesIgnoringSafeArea(.bottom)
            }
        }
    }
}

extension View {
    func customBottomSheet<SheetContent: View>(
        isPresented: Binding<Bool>,
        sheetContent: @escaping () -> SheetContent
    ) -> some View {
        self.modifier(BottomSheet(isPresented: isPresented, sheetContent: sheetContent))
    }
}

并且使用如下:

.customBottomSheet(isPresented: $isPickerPresented) {
                DatePicker(
                    "time",
                    selection: self.$time,
                    displayedComponents: .hourAndMinute
                )
                .labelsHidden()
        }

7
从Beta 3版本开始,你不能将modal View呈现为.fullScreen,它会以.automatic -> .pageSheet形式呈现。即使那个问题被修复了,我非常怀疑他们会免费给你提供拖动功能。如果有的话,已经包含在文档中了。
目前你可以使用这个答案来实现全屏功能。此处Gist提供了更多信息。
之后,在展示完成后,以下是如何重新创建该交互的一个快速而简单的示例。
    @State var drag: CGFloat = 0.0

    var body: some View {
        ZStack(alignment: .bottom) {
            Spacer() // Use the full space
            Color.red
                .frame(maxHeight: 300 + self.drag) // Whatever minimum height you want, plus the drag offset
                .gesture(
                    DragGesture(coordinateSpace: .global) // if you use .local the frame will jump around
                        .onChanged({ (value) in
                            self.drag = max(0, -value.translation.height)
                        })
                )
        }
    }

7

5
我认为几乎所有在SwiftUI中编写任何内容的iOS开发人员都会遇到这个问题。我肯定也遇到了,但我认为这里大部分答案要么太复杂,要么并没有真正提供我想要的。
我编写了一个非常简单的部分表格,它在GitHub上作为Swift软件包可用 - HalfASheet
它可能没有其他解决方案那么高级,但它做到了它需要做的事情。此外,编写自己的代码总是有助于理解正在发生的事情。
注意-一些事情-首先,这仍然是一个正在进行中的工作,请随意改进等。其次,我故意没有做.podspec,因为如果您正在为SwiftUI开发,则最低要求为iOS 13,并且在我看来,Swift Packages更好用...

当使用声明式方式时,如何设置大小? - Farhandika
@Farhandika,这还是一个正在进行中的工作,几乎肯定需要进一步的努力才能将其变成一个强大的、实用的解决方案。请随意查看代码并尝试改进它。 - SomaMan
在我看来,这并不能否认你忽略了一部分非常重要的当前iOS项目,这些项目已经存在多年。至少要诚实一点 - 你不想花力气去创建podspec。没有其他原因,只是因为你不想做而已。 - tsalaroth

3

>>WWDC22更新
您可以使用本教程在02:40分钟内创建半模态或小模态。这是一种令人印象深刻的方式,可以调整模态而不使用任何复杂的代码。只需关注演示。

视频链接:点击此处

让我们从用法开始:

.sheet(isPresented : yourbooleanvalue) {
  //place some content inside
  Text("test")
    .presentationDetents([.medium,.large])
}

通过这种方式,您可以设置一个模态框,在开始时可以是中等大小,并可拖动变为较大。但是您也可以在这些尺寸参数中使用.small属性。我认为这是最短的路径和最易于使用的方法。现在,这种方法让我免于编写数千行代码。


2

Andre Carrera的回答很棒,你可以放心使用他提供的指南:https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks

我修改了SlideOverCard结构,使其使用实际设备高度来测量卡片停止的位置(你可以通过调整bounds.height来适应自己的需求):

struct SlideOverCard<Content: View>: View {

    var bounds = UIScreen.main.bounds
    @GestureState private var dragState = DragState.inactive
    @State var position = UIScreen.main.bounds.height/2

    var content: () -> Content
    var body: some View {
        let drag = DragGesture()
            .updating($dragState) { drag, state, transaction in
                state = .dragging(translation: drag.translation)
            }
            .onEnded(onDragEnded)

        return Group {
            Handle()
            self.content()
        }
        .frame(height: UIScreen.main.bounds.height)
        .background(Color.white)
        .cornerRadius(10.0)
        .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
        .offset(y: self.position + self.dragState.translation.height)
        .animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
        .gesture(drag)
    }

    private func onDragEnded(drag: DragGesture.Value) {
        let verticalDirection = drag.predictedEndLocation.y - drag.location.y
        let cardTopEdgeLocation = self.position + drag.translation.height
        let positionAbove: CGFloat
        let positionBelow: CGFloat
        let closestPosition: CGFloat

        if cardTopEdgeLocation <= bounds.height/2 {
            positionAbove = bounds.height/7
            positionBelow = bounds.height/2
        } else {
            positionAbove = bounds.height/2
            positionBelow = bounds.height - (bounds.height/9)
        }

        if (cardTopEdgeLocation - positionAbove) < (positionBelow - cardTopEdgeLocation) {
            closestPosition = positionAbove
        } else {
            closestPosition = positionBelow
        }

        if verticalDirection > 0 {
            self.position = positionBelow
        } else if verticalDirection < 0 {
            self.position = positionAbove
        } else {
            self.position = closestPosition
        }
    }
}

enum DragState {
    case inactive
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接