mirror of
https://github.com/exyte/PopupView.git
synced 2026-06-06 20:18:33 +00:00
402 lines
12 KiB
Swift
402 lines
12 KiB
Swift
//
|
|
// PopupView.swift
|
|
// PopupView
|
|
//
|
|
// Created by Alisa Mylnikova on 23/04/2020.
|
|
// Copyright © 2020 Exyte. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
public enum DismissSource {
|
|
case binding // set isPresented to false ot item to nil
|
|
case tapInside
|
|
case tapOutside
|
|
case drag
|
|
case autohide
|
|
}
|
|
|
|
public struct Popup<PopupContent: View>: ViewModifier {
|
|
|
|
init(params: Popup<PopupContent>.PopupParameters,
|
|
view: @escaping () -> PopupContent,
|
|
shouldShowContent: Bool = true,
|
|
showContent: Bool = true,
|
|
animationCompletedCallback: @escaping () -> (),
|
|
dismissCallback: @escaping (DismissSource)->()) {
|
|
|
|
self.type = params.type
|
|
self.position = params.position
|
|
self.appearFrom = params.appearFrom
|
|
self.animation = params.animation
|
|
self.dragToDismiss = params.dragToDismiss
|
|
self.closeOnTap = params.closeOnTap
|
|
self.opaqueBackground = params.isOpaque || params.closeOnTapOutside
|
|
|
|
self.view = view
|
|
|
|
self.shouldShowContent = shouldShowContent
|
|
self.showContent = showContent
|
|
self.animationCompletedCallback = animationCompletedCallback
|
|
self.dismissCallback = dismissCallback
|
|
}
|
|
|
|
public enum PopupType {
|
|
case `default`
|
|
case toast
|
|
case floater(verticalPadding: CGFloat = 10, useSafeAreaInset: Bool = true)
|
|
}
|
|
|
|
public enum Position {
|
|
case top
|
|
case bottom
|
|
}
|
|
|
|
public enum AppearFrom {
|
|
case top
|
|
case bottom
|
|
case left
|
|
case right
|
|
}
|
|
|
|
public struct PopupParameters {
|
|
var type: PopupType = .default
|
|
|
|
var position: Position = .bottom
|
|
|
|
var appearFrom: AppearFrom?
|
|
|
|
var animation: Animation = .easeOut(duration: 0.3)
|
|
|
|
/// If nil - never hides on its own
|
|
var autohideIn: Double?
|
|
|
|
/// Should allow dismiss by dragging
|
|
var dragToDismiss: Bool = true
|
|
|
|
/// Should close on tap - default is `true`
|
|
var closeOnTap: Bool = true
|
|
|
|
/// Should close on tap outside - default is `true`
|
|
var closeOnTapOutside: Bool = false
|
|
|
|
/// Background color for outside area
|
|
var backgroundColor: Color = .clear
|
|
|
|
/// Custom background view for outside area
|
|
var backgroundView: AnyView?
|
|
|
|
/// If true taps do not pass through popup's background and the popup is displayed on top of navbar. Always opaque if closeOnTapOutside is true
|
|
var isOpaque: Bool = false
|
|
|
|
var dismissCallback: (DismissSource) -> () = {_ in}
|
|
|
|
public func type(_ type: PopupType) -> PopupParameters {
|
|
var params = self
|
|
params.type = type
|
|
return params
|
|
}
|
|
|
|
public func position(_ position: Position) -> PopupParameters {
|
|
var params = self
|
|
params.position = position
|
|
return params
|
|
}
|
|
|
|
public func appearFrom(_ appearFrom: AppearFrom) -> PopupParameters {
|
|
var params = self
|
|
params.appearFrom = appearFrom
|
|
return params
|
|
}
|
|
|
|
public func animation(_ animation: Animation) -> PopupParameters {
|
|
var params = self
|
|
params.animation = animation
|
|
return params
|
|
}
|
|
|
|
public func autohideIn(_ autohideIn: Double?) -> PopupParameters {
|
|
var params = self
|
|
params.autohideIn = autohideIn
|
|
return params
|
|
}
|
|
|
|
public func dragToDismiss(_ dragToDismiss: Bool) -> PopupParameters {
|
|
var params = self
|
|
params.dragToDismiss = dragToDismiss
|
|
return params
|
|
}
|
|
|
|
public func closeOnTap(_ closeOnTap: Bool) -> PopupParameters {
|
|
var params = self
|
|
params.closeOnTap = closeOnTap
|
|
return params
|
|
}
|
|
|
|
public func closeOnTapOutside(_ closeOnTapOutside: Bool) -> PopupParameters {
|
|
var params = self
|
|
params.closeOnTapOutside = closeOnTapOutside
|
|
return params
|
|
}
|
|
|
|
public func backgroundColor(_ backgroundColor: Color) -> PopupParameters {
|
|
var params = self
|
|
params.backgroundColor = backgroundColor
|
|
return params
|
|
}
|
|
|
|
public func backgroundView<BackgroundView: View>(_ backgroundView: ()->(BackgroundView)) -> PopupParameters {
|
|
var params = self
|
|
params.backgroundView = AnyView(backgroundView())
|
|
return params
|
|
}
|
|
|
|
public func isOpaque(_ isOpaque: Bool) -> PopupParameters {
|
|
var params = self
|
|
params.isOpaque = isOpaque
|
|
return params
|
|
}
|
|
|
|
public func dismissSourceCallback(_ dismissCallback: @escaping (DismissSource) -> ()) -> PopupParameters {
|
|
var params = self
|
|
params.dismissCallback = dismissCallback
|
|
return params
|
|
}
|
|
|
|
public func dismissCallback(_ dismissCallback: @escaping () -> ()) -> PopupParameters {
|
|
var params = self
|
|
params.dismissCallback = { _ in
|
|
dismissCallback()
|
|
}
|
|
return params
|
|
}
|
|
}
|
|
|
|
private 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Public Properties
|
|
|
|
var type: PopupType
|
|
var position: Position
|
|
var appearFrom: AppearFrom?
|
|
|
|
var animation: Animation
|
|
|
|
/// Should close on tap - default is `true`
|
|
var closeOnTap: Bool
|
|
|
|
/// Should allow dismiss by dragging
|
|
var dragToDismiss: Bool
|
|
|
|
/// If opaque taps do not pass through popup's background color. True if either closeOnTapOutside or isOpaque is true
|
|
var opaqueBackground: Bool
|
|
|
|
/// Trigger popup showing/hiding animations and...
|
|
var shouldShowContent: Bool
|
|
|
|
/// ... once hiding animation is finished remove popup from the memory using this flag
|
|
var showContent: Bool
|
|
|
|
/// called on showing/hiding sliding animation completed
|
|
var animationCompletedCallback: () -> ()
|
|
|
|
/// Call dismiss callback with dismiss source
|
|
var dismissCallback: (DismissSource)->()
|
|
|
|
var view: () -> PopupContent
|
|
|
|
// MARK: - Private Properties
|
|
|
|
@StateObject var keyboardHeightHelper = KeyboardHeightHelper()
|
|
|
|
/// The rect and safe area of the hosting controller
|
|
@State private var presenterContentRect: CGRect = .zero
|
|
|
|
/// The rect and safe area of popup content
|
|
@State private var sheetContentRect: CGRect = .zero
|
|
|
|
@State private var safeAreaInsets: EdgeInsets = EdgeInsets()
|
|
|
|
/// Drag to dismiss gesture state
|
|
@GestureState private var dragState = DragState.inactive
|
|
|
|
/// Last position for drag gesture
|
|
@State private var lastDragPosition: CGFloat = 0
|
|
|
|
/// The offset when the popup is displayed
|
|
private var displayedOffsetY: CGFloat {
|
|
if opaqueBackground {
|
|
switch type {
|
|
case .`default`:
|
|
return (screenHeight - sheetContentRect.height)/2 - safeAreaInsets.top
|
|
case .toast:
|
|
if position == .bottom {
|
|
return screenHeight - sheetContentRect.height - keyboardHeightHelper.keyboardHeight - safeAreaInsets.top
|
|
} else {
|
|
return -safeAreaInsets.top
|
|
}
|
|
case .floater(let verticalPadding, let useSafeAreaInset):
|
|
if position == .bottom {
|
|
return screenHeight - sheetContentRect.height - keyboardHeightHelper.keyboardHeight - verticalPadding + (useSafeAreaInset ? -safeAreaInsets.bottom : 0) - safeAreaInsets.top
|
|
} else {
|
|
return verticalPadding + (useSafeAreaInset ? 0 : -safeAreaInsets.top)
|
|
}
|
|
}
|
|
}
|
|
|
|
switch type {
|
|
case .`default`:
|
|
return (presenterContentRect.height - sheetContentRect.height)/2
|
|
case .toast:
|
|
if position == .bottom {
|
|
return presenterContentRect.height - sheetContentRect.height + safeAreaInsets.bottom + (keyboardHeightHelper.keyboardDisplayed ? -safeAreaInsets.bottom : 0)
|
|
} else {
|
|
return -safeAreaInsets.top
|
|
}
|
|
case .floater(let verticalPadding, let useSafeAreaInset):
|
|
if position == .bottom {
|
|
return presenterContentRect.height - sheetContentRect.height + safeAreaInsets.bottom + verticalPadding + (useSafeAreaInset ? -safeAreaInsets.bottom : 0) - 28
|
|
} else {
|
|
return verticalPadding + (useSafeAreaInset ? 0 : -safeAreaInsets.top)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The offset when the popup is hidden
|
|
private var hiddenOffset: CGPoint {
|
|
let from: AppearFrom
|
|
if let appearFrom = appearFrom {
|
|
from = appearFrom
|
|
}
|
|
else {
|
|
from = position == .top ? .top : .bottom
|
|
}
|
|
|
|
switch from {
|
|
case .top:
|
|
if sheetContentRect.isEmpty {
|
|
return CGPoint(x: 0, y: -screenHeight * 2)
|
|
}
|
|
return CGPoint(x: 0, y: -presenterContentRect.minY - safeAreaInsets.top - sheetContentRect.height)
|
|
case .bottom:
|
|
if sheetContentRect.isEmpty {
|
|
return CGPoint(x: 0, y: screenHeight * 2)
|
|
}
|
|
return CGPoint(x: 0, y: screenHeight)
|
|
case .left:
|
|
return CGPoint(x: -screenSize.width, y: displayedOffsetY)
|
|
case .right:
|
|
return CGPoint(x: screenSize.width, y: displayedOffsetY)
|
|
}
|
|
}
|
|
|
|
/// The current offset, based on the **presented** property
|
|
private var currentOffset: CGPoint {
|
|
return shouldShowContent ? CGPoint(x: 0, y: displayedOffsetY) : hiddenOffset
|
|
}
|
|
|
|
private var screenSize: CGSize {
|
|
#if os(iOS)
|
|
return UIScreen.main.bounds.size
|
|
#elseif os(watchOS)
|
|
return WKInterfaceDevice.current().screenBounds.size
|
|
#else
|
|
return CGSize(width: presenterContentRect.size.width, height: presenterContentRect.size.height - presenterContentRect.minY)
|
|
#endif
|
|
}
|
|
|
|
private var screenHeight: CGFloat {
|
|
screenSize.height
|
|
}
|
|
|
|
// MARK: - Content Builders
|
|
|
|
public func body(content: Content) -> some View {
|
|
content
|
|
.frameGetter($presenterContentRect)
|
|
.safeAreaGetter($safeAreaInsets)
|
|
.overlay(
|
|
Group {
|
|
if showContent, presenterContentRect != .zero {
|
|
sheet()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
/// This is the builder for the sheet content
|
|
func sheet() -> some View {
|
|
let sheet = ZStack {
|
|
self.view()
|
|
.addTapIfNotTV(if: closeOnTap) {
|
|
dismissCallback(.tapInside)
|
|
}
|
|
.frameGetter($sheetContentRect)
|
|
.position(x: (screenSize.width - safeAreaInsets.leading - safeAreaInsets.trailing)/2 + currentOffset.x, y: sheetContentRect.height/2 + currentOffset.y)
|
|
//.onAnimationCompleted(for: currentOffset) {
|
|
//animationCompletedCallback() TEMP: need to fix
|
|
//}
|
|
.animation(animation, value: currentOffset)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
let drag = DragGesture()
|
|
.updating($dragState) { drag, state, _ in
|
|
state = .dragging(translation: drag.translation)
|
|
}
|
|
.onEnded(onDragEnded)
|
|
|
|
return sheet
|
|
.applyIf(dragToDismiss) {
|
|
$0.offset(y: dragOffset())
|
|
.simultaneousGesture(drag)
|
|
}
|
|
#else
|
|
return sheet
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
func dragOffset() -> CGFloat {
|
|
if (position == .bottom && dragState.translation.height > 0) ||
|
|
(position == .top && dragState.translation.height < 0) {
|
|
return dragState.translation.height
|
|
}
|
|
return lastDragPosition
|
|
}
|
|
|
|
private func onDragEnded(drag: DragGesture.Value) {
|
|
let reference = sheetContentRect.height / 3
|
|
if (position == .bottom && drag.translation.height > reference) ||
|
|
(position == .top && drag.translation.height < -reference) {
|
|
lastDragPosition = drag.translation.height
|
|
withAnimation {
|
|
lastDragPosition = 0
|
|
}
|
|
dismissCallback(.drag)
|
|
}
|
|
}
|
|
#endif
|
|
}
|