68

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

这是它的样子:

在此处输入图像描述

有谁知道这在SwiftUI中是否可行?我想显示一个小的半模态,可以选择拖动到全屏,就像共享表一样。

非常感谢任何建议!

4

11 回答 11

23

我编写了一个 Swift 包,其中包含一个自定义修饰符,允许您使用半模态表。

这是链接:https ://github.com/AndreaMiotto/PartialSheet

随意使用它或贡献

在此处输入图像描述

于 2019-11-11T14:06:10.463 回答
15

在 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()
    }
}
于 2021-06-15T23:42:40.693 回答
14

您可以自己制作并将其放在 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
        }
    }
}
于 2019-07-11T20:44:47.367 回答
7

作为测试版 2Beta 3 您不能将模态呈现View.fullScreen. 它呈现为.automatic -> .pageSheet。但是,即使解决了这个问题,我也非常怀疑他们是否会免费为您提供拖动功能。它已经包含在文档中。

您现在可以使用此答案显示全屏。 要点在这里

然后,在演示之后,这是一个快速而肮脏的示例,说明如何重新创建该交互。

    @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)
                        })
                )
        }
    }
于 2019-07-02T02:54:40.263 回答
7

这是我的幼稚底部表,可缩放到其内容。无需拖动,但如果需要添加应该相对容易:)

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()
        }
于 2020-01-08T18:59:05.967 回答
6

我编写了一个 SwiftUI 包,其中包括自定义 iOS 13,如半模态及其按钮。

GitHub 仓库:https ://github.com/ViktorMaric/HalfModal

半模态预览

于 2020-02-21T16:11:25.567 回答
5

我认为几乎每个在 SwiftUI 中编写任何东西的 iOS 开发人员都必须反对这一点。我当然做到了,但我认为这里的大多数答案要么太复杂,要么并没有真正提供我想要的。

我在 GitHub 上写了一个非常简单的部分工作表,可作为 Swift 包使用 - HalfASheet

它可能没有其他一些解决方案的花里胡哨,但它做了它需要做的事情。另外,自己编写总是有助于理解正在发生的事情。

注意 - 有几件事 - 首先,这是一个正在进行中的工作,请随时改进,等等。其次,我故意不做一个 .podspec,就好像你正在为 SwiftUI 开发一样你至少在 iOS 13 上,我认为 Swift 包要好得多......

于 2021-02-04T17:03:25.690 回答
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
        }
    }
}
于 2020-02-09T20:01:12.697 回答
1

我试图做同样的事情,在 SwiftUI 中以本机方式显示共享表,而不必实现/导入组件。我在https://jeevatamil.medium.com/how-to-create-share-sheet-uiactivityviewcontroller-in-swiftui-cef64b26f073找到了这个解决方案

struct ShareSheetView: View {
    var body: some View {
        Button(action: actionSheet) {
            Image(systemName: "square.and.arrow.up")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 36, height: 36)
        }
    }
    
    func actionSheet() {
        guard let data = URL(string: "https://www.zoho.com") else { return }
        let av = UIActivityViewController(activityItems: [data], applicationActivities: nil)
        UIApplication.shared.windows.first?.rootViewController?.present(av, animated: true, completion: nil)
    }
}
于 2021-05-05T20:37:59.727 回答
0

对于更通用的解决方案,我提出了以下想法: https ://github.com/mtzaquia/UIKitPresentationModifier

这是一个通用修饰符,允许您在视图中使用UIKit演示文稿。SwiftUI

从那里开始,世界就是你的牡蛎。唯一的缺点是您可能需要将自定义环境值从呈现视图级联到呈现视图。

myPresentingView
  .presentation(isPresented: $isPresented) {
    MyPresentedView()
  } controllerProvider: { content in
    let controller = UIHostingController(rootView: content)
    if #available(iOS 15, *) {
      if let sheet = controller.sheetPresentationController {
        sheet.preferredCornerRadius = 12
        sheet.prefersGrabberVisible = true
      }
    }
    
    return controller
  }
于 2021-10-02T10:07:55.630 回答
0

至少在 iOS 14、Swift 5、Xcode 12.5 中,我可以通过简单地UIActivityViewController将 它不需要检查视图层次结构或使用任何 3rd 方库。唯一的黑客部分是异步呈现视图控制器,这甚至可能不是必需的。有更多 SwiftUI 经验的人可能会提供改进建议。

import Foundation
import SwiftUI
import UIKit

struct ActivityViewController: UIViewControllerRepresentable {
        
    @Binding var shareURL: URL?
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> some UIViewController {
        let containerViewController = UIViewController()
        
        return containerViewController

    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        guard let shareURL = shareURL, context.coordinator.presented == false else { return }
        
        context.coordinator.presented = true

        let activityViewController = UIActivityViewController(activityItems: [shareURL], applicationActivities: nil)
        activityViewController.completionWithItemsHandler = { activity, completed, returnedItems, activityError in
            self.shareURL = nil
            context.coordinator.presented = false

            if completed {
                // ...
            } else {
                // ...
            }
        }
        
        // Executing this asynchronously might not be necessary but some of my tests
        // failed because the view wasn't yet in the view hierarchy on the first pass of updateUIViewController
        //
        // There might be a better way to test for that condition in the guard statement and execute this
        // synchronously if we can be be sure updateUIViewController is invoked at least once after the view is added
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            uiViewController.present(activityViewController, animated: true)
        }
    }
    
    class Coordinator: NSObject {
        let parent: ActivityViewController
        
        var presented: Bool = false
        
        init(_ parent: ActivityViewController) {
            self.parent = parent
        }
    }
    
}
struct ContentView: View {
    
    @State var shareURL: URL? = nil
    
    var body: some View {
        ZStack {
            Button(action: { shareURL = URL(string: "https://apple.com") }) {
                Text("Share")
                    .foregroundColor(.white)
                    .padding()
            }
            .background(Color.blue)
            if shareURL != nil {
                ActivityViewController(shareURL: $shareURL)
            }
        }
        .frame(width: 375, height: 812)
    }
}
于 2021-09-03T12:59:19.020 回答