SwiftUI - Half modal?

2020-05-14 10:21发布

I'm trying to recreate a Modal just like Safari in iOS13 in SwiftUI:

Here's what it looks like:

enter image description here

Does anyone know if this is possible in SwiftUI? I want to show a small half modal, with the option to drag to fullscreen, just like the sharing sheet.

Any advice is much appreciated!

6条回答
甜甜的少女心
2楼-- · 2020-05-14 10:28

You can make your own and place it inside of a 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楼-- · 2020-05-14 10:32

As of Beta 2 Beta 3 you can't present a modal View as .fullScreen. It presents as .automatic -> .pageSheet. Even once that's fixed, though, I highly doubt they will give you the drag capability there for free. It would be included in the docs already.

You can use this answer to present full screen for now. Gist here.

Then, after presentation, this is a quick and dirty example of how you can recreate that interaction.

    @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)
                        })
                )
        }
    }
查看更多
▲ chillily
4楼-- · 2020-05-14 10:33

Andre Carrera's answer is great and feel free to use this guide he provided: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks

I have modified the SlideOverCard structure so it uses actual device height to measure where the card is supposed to stop (you can play with bounds.height to adjust for your needs):

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
        }
    }
}
查看更多
做自己的国王
5楼-- · 2020-05-14 10:40

I've written a Swift Package that includes a custom modifier that allows you to use the half modal sheet.

Here is the link: https://github.com/AndreaMiotto/PartialSheet

Feel free to use it or to contribute

enter image description here

查看更多
仙女界的扛把子
6楼-- · 2020-05-14 10:43

I have written a SwiftUI package which includes custom iOS 13 like half modal and its buttons.

GitHub repo: https://github.com/ViktorMaric/HalfModal

Preview of HalfModal

查看更多
【Aperson】
7楼-- · 2020-05-14 10:43

Here's my naive bottom sheet which scales to its content. Without dragging but it should be relatively easy to add if needed :)

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))
    }
}

and use like below:

.customBottomSheet(isPresented: $isPickerPresented) {
                DatePicker(
                    "time",
                    selection: self.$time,
                    displayedComponents: .hourAndMinute
                )
                .labelsHidden()
        }
查看更多
登录 后发表回答