Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ public final class GlobalConfigCenter: GlobalConfig { required public init() {}
public var isDragGestureEnabled: Bool = false
public var dragThreshold: CGFloat = 0
public var isStackingEnabled: Bool = false
public var dragGestureAreaSize: CGFloat = 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public final class GlobalConfigVertical: GlobalConfig { required public init() {
public var isTapOutsideToDismissEnabled: Bool = false
public var isDragGestureEnabled: Bool = true
public var dragThreshold: CGFloat = 1/3
public var dragGestureAreaSize: CGFloat = 30

// MARK: Non-Customizable
public var ignoredSafeAreaEdges: Edge.Set = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ public class LocalConfigCenter: LocalConfig { required public init() {}
public var heightMode: HeightMode = GlobalConfigContainer.center.heightMode
public var dragDetents: [DragDetent] = GlobalConfigContainer.center.dragDetents
public var isDragGestureEnabled: Bool = GlobalConfigContainer.center.isDragGestureEnabled
public var dragGestureAreaSize: CGFloat = GlobalConfigContainer.center.dragGestureAreaSize
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class LocalConfigVertical: LocalConfig { required public init() {}
// MARK: Gestures
public var isTapOutsideToDismissEnabled: Bool = GlobalConfigContainer.vertical.isTapOutsideToDismissEnabled
public var isDragGestureEnabled: Bool = GlobalConfigContainer.vertical.isDragGestureEnabled
public var dragGestureAreaSize: CGFloat = GlobalConfigContainer.vertical.dragGestureAreaSize
}

// MARK: Subclasses
Expand Down
1 change: 1 addition & 0 deletions Sources/Internal/Configurables/Local/LocalConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ public protocol LocalConfig { init()
// MARK: Gestures
var isTapOutsideToDismissEnabled: Bool { get set }
var isDragGestureEnabled: Bool { get set }
var dragGestureAreaSize: CGFloat { get set }
}
8 changes: 4 additions & 4 deletions Sources/Internal/Extensions/View+Gestures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ extension View {

// MARK: On Drag Gesture
extension View {
func onDragGesture(onChanged actionOnChanged: @escaping (CGFloat) async -> (), onEnded actionOnEnded: @escaping (CGFloat) async -> (), isEnabled: Bool) -> some View {
func onDragGesture(onChanged actionOnChanged: @escaping (DragGestureState) async -> (), onEnded actionOnEnded: @escaping (DragGestureState) async -> (), isEnabled: Bool) -> some View {
#if os(tvOS)
self
#else
highPriorityGesture(
simultaneousGesture(
DragGesture()
.onChanged { newValue in Task { @MainActor in await actionOnChanged(newValue.translation.height) }}
.onEnded { newValue in Task { @MainActor in await actionOnEnded(newValue.translation.height) }},
.onChanged { newValue in Task { @MainActor in await actionOnChanged(DragGestureState(newValue)) }}
.onEnded { newValue in Task { @MainActor in await actionOnEnded(DragGestureState(newValue)) }},
isEnabled: isEnabled
)
#endif
Expand Down
2 changes: 2 additions & 0 deletions Sources/Internal/Models/AnyPopupConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct AnyPopupConfig: LocalConfig, Sendable { init() {}
// MARK: Gestures
var isTapOutsideToDismissEnabled: Bool = false
var isDragGestureEnabled: Bool = false
var dragGestureAreaSize: CGFloat = 0
}

// MARK: Initialize
Expand All @@ -40,5 +41,6 @@ extension AnyPopupConfig {
self.dragDetents = config.dragDetents
self.isTapOutsideToDismissEnabled = config.isTapOutsideToDismissEnabled
self.isDragGestureEnabled = config.isDragGestureEnabled
self.dragGestureAreaSize = config.dragGestureAreaSize
}
}
23 changes: 23 additions & 0 deletions Sources/Internal/Models/DragGestureState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// DragGestureState.swift of MijickPopups
//
// Created by Alina Petrovska
// - Mail: [email protected]
// - GitHub: https://github.com/alina-p-k
//
// Copyright ©2025 Mijick. All rights reserved.


Comment thread
alina-p-k marked this conversation as resolved.
import SwiftUI

struct DragGestureState {
let startLocationY: Double
let height: Double
}

extension DragGestureState {
init(_ value: DragGesture.Value) {
self.startLocationY = value.startLocation.y
self.height = value.translation.height
}
}
7 changes: 4 additions & 3 deletions Sources/Internal/UI/PopupVerticalStackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ struct PopupVerticalStackView: View {
var body: some View { if viewModel.screen.height > 0 {
ZStack(alignment: (!viewModel.alignment).toAlignment(), content: createPopupStack)
.frame(height: viewModel.screen.height, alignment: viewModel.alignment.toAlignment())
.onDragGesture(onChanged: viewModel.onPopupDragGestureChanged, onEnded: viewModel.onPopupDragGestureEnded, isEnabled: viewModel.dragGestureEnabled)
}}
}
private extension PopupVerticalStackView {
Expand All @@ -33,15 +32,16 @@ private extension PopupVerticalStackView {
.padding(viewModel.activePopupProperties.innerPadding)
.fixedSize(horizontal: false, vertical: viewModel.activePopupProperties.verticalFixedSize)
.onHeightChange { await viewModel.updatePopupHeight($0, popup) }
.frame(height: viewModel.activePopupProperties.height, alignment: (!viewModel.alignment).toAlignment())
.frame(maxWidth: .infinity, maxHeight: viewModel.activePopupProperties.height, alignment: (!viewModel.alignment).toAlignment())
.frame(height: viewModel.activePopupProperties.height, alignment: popupAlignment)
.frame(maxWidth: .infinity, maxHeight: viewModel.activePopupProperties.height, alignment: popupAlignment)
.background(backgroundColor: getBackgroundColor(for: popup), overlayColor: getStackOverlayColor(for: popup), corners: viewModel.activePopupProperties.corners)
.offset(y: viewModel.calculateOffsetY(for: popup))
.scaleEffect(x: viewModel.calculateScaleX(for: popup))
.focusSection_tvOS()
.padding(viewModel.activePopupProperties.outerPadding)
.transition(transition)
.zIndex(viewModel.calculateZIndex())
.onDragGesture(onChanged: viewModel.onPopupDragGestureChanged, onEnded: viewModel.onPopupDragGestureEnded, isEnabled: viewModel.dragGestureEnabled)
}}
}

Expand All @@ -52,4 +52,5 @@ private extension PopupVerticalStackView {
private extension PopupVerticalStackView {
var stackOverlayColor: Color { .black }
var transition: AnyTransition { .move(edge: viewModel.alignment.toEdge()) }
var popupAlignment: Alignment { (!viewModel.alignment).toAlignment() }
}
25 changes: 20 additions & 5 deletions Sources/Internal/View Models/ViewModel+VerticalStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,14 +310,29 @@ extension VM.VerticalStack {

// MARK: On Changed
extension VM.VerticalStack {
func onPopupDragGestureChanged(_ value: CGFloat) async {
guard dragGestureEnabled else { return }
func onPopupDragGestureChanged(_ value: DragGestureState) async {
guard dragGestureEnabled, isValidDragGesture(value) else { return }

let newGestureTranslation = await calculateGestureTranslation(value)
let newGestureTranslation = await calculateGestureTranslation(value.height)
await updateGestureTranslation(newGestureTranslation)
}
}
private extension VM.VerticalStack {
func isValidDragGesture(_ value: DragGestureState) -> Bool {
guard popups.isEmpty == false else { return false }
guard let gestureAreaSize = popups.last?.config.dragGestureAreaSize, gestureAreaSize >= 0 else { return false }

var minStartPointOffset: CGFloat {
if screen.height <= activePopupProperties.height ?? 0 { return screen.safeArea.top + gestureAreaSize }
return gestureAreaSize
}
let popupHeight = activePopupProperties.height ?? minStartPointOffset

switch popups.last?.config.alignment {
case .top: return popupHeight - minStartPointOffset <= value.startLocationY
default: return value.startLocationY <= minStartPointOffset
}
}
func calculateGestureTranslation(_ value: CGFloat) async -> CGFloat { switch popups.last?.config.dragDetents.isEmpty ?? true {
case true: calculateGestureTranslationWhenNoDragDetents(value)
case false: calculateGestureTranslationWhenDragDetents(value)
Expand Down Expand Up @@ -355,8 +370,8 @@ private extension VM.VerticalStack {

// MARK: On Ended
extension VM.VerticalStack {
func onPopupDragGestureEnded(_ value: CGFloat) async {
guard value != 0, let activePopup = popups.last else { return }
func onPopupDragGestureEnded(_ value: DragGestureState) async {
guard value.height != 0, let activePopup = popups.last, isValidDragGesture(value) else { return }

await dismissLastPopupIfNeeded(activePopup)

Expand Down
11 changes: 11 additions & 0 deletions Sources/Public/Popup/Public+Popup+Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,15 @@ public extension LocalConfigVertical {
![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/enable-drag-gesture.png?raw=true)
*/
func enableDragGesture(_ value: Bool) -> Self { self.isDragGestureEnabled = value; return self }

/**
Defines the vertical size (in points) of the area that responds to dismissal drag gesture.

Use this to control how much of the popup’s top/bottom region is draggable for dismiss gesture.
A larger value allows dragging from a wider area.

## Visualisation
![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-popup-draggable-area.png?raw=true)
*/
func dragGestureAreaSize(_ value: CGFloat) -> Self { self.dragGestureAreaSize = value; return self }
}
11 changes: 11 additions & 0 deletions Sources/Public/Setup/Public+Setup+Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,15 @@ public extension GlobalConfigVertical {
- important: Drag progress is calculated as **dragTranslation** / **popupHeight**, therefore drag threshold value is expected to be between 0 and 1.
*/
func dragThreshold(_ value: CGFloat) -> Self { self.dragThreshold = value; return self }

/**
Defines the vertical size (in points) of the area that responds to dismissal drag gestures.

Use this to control how much of the popup’s top/bottom region is draggable for dismiss gesture.
A larger value allows dragging from a wider area.

## Visualisation
![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-popup-draggable-area.png?raw=true)
*/
func dragGestureAreaSize(_ value: CGFloat) -> Self { self.dragGestureAreaSize = value; return self }
}
15 changes: 10 additions & 5 deletions Tests/Tests+ViewModel+PopupVerticalStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1287,16 +1287,17 @@ extension PopupVerticalStackViewModelTests {
viewModel: topViewModel,
popups: popups,
gestureValue: -133,
gestureStartLocation: 1000,
expectedValues: (popupHeight: 344, gestureTranslation: -133)
)
}
}
private extension PopupVerticalStackViewModelTests {
func appendPopupsAndCheckGestureTranslationOnChange(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, expectedValues: (popupHeight: CGFloat, gestureTranslation: CGFloat)) async {
func appendPopupsAndCheckGestureTranslationOnChange(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, gestureStartLocation: CGFloat = 0, expectedValues: (popupHeight: CGFloat, gestureTranslation: CGFloat)) async {
await viewModel.updatePopups(popups)
await updatePopups(viewModel)
await viewModel.onPopupDragGestureChanged(gestureValue)

await viewModel.onPopupDragGestureChanged(.init(startLocationY: gestureStartLocation, height: gestureValue))
XCTAssertEqual(viewModel.activePopupProperties.height, expectedValues.popupHeight)
XCTAssertEqual(viewModel.activePopupProperties.gestureTranslation, expectedValues.gestureTranslation)
}
Expand Down Expand Up @@ -1385,6 +1386,7 @@ extension PopupVerticalStackViewModelTests {
viewModel: topViewModel,
popups: popups,
gestureValue: -300,
gestureStartLocation: 1000,
expectedValues: (popupHeight: nil, shouldPopupBeDismissed: true)
)
}
Expand Down Expand Up @@ -1433,6 +1435,7 @@ extension PopupVerticalStackViewModelTests {
viewModel: topViewModel,
popups: popups,
gestureValue: 400,
gestureStartLocation: 1000,
expectedValues: (popupHeight: 400, shouldPopupBeDismissed: false)
)
}
Expand All @@ -1445,6 +1448,7 @@ extension PopupVerticalStackViewModelTests {
viewModel: topViewModel,
popups: popups,
gestureValue: 100,
gestureStartLocation: 1000,
expectedValues: (popupHeight: 400, shouldPopupBeDismissed: false)
)
}
Expand All @@ -1457,16 +1461,17 @@ extension PopupVerticalStackViewModelTests {
viewModel: topViewModel,
popups: popups,
gestureValue: 400,
gestureStartLocation: 1000,
expectedValues: (popupHeight: screen.height - screen.safeArea.bottom, shouldPopupBeDismissed: false)
)
}
}
private extension PopupVerticalStackViewModelTests {
func appendPopupsAndCheckGestureTranslationOnEnd(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, expectedValues: (popupHeight: CGFloat?, shouldPopupBeDismissed: Bool)) async {
func appendPopupsAndCheckGestureTranslationOnEnd(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, gestureStartLocation: CGFloat = 0, expectedValues: (popupHeight: CGFloat?, shouldPopupBeDismissed: Bool)) async {
await viewModel.updatePopups(popups)
await updatePopups(viewModel)
await viewModel.updateGestureTranslation(gestureValue)
await viewModel.onPopupDragGestureEnded(gestureValue)
await viewModel.onPopupDragGestureEnded(.init(startLocationY: gestureStartLocation, height: gestureValue))

XCTAssertEqual(viewModel.popups.count, expectedValues.shouldPopupBeDismissed ? 0 : 1)
XCTAssertEqual(viewModel.activePopupProperties.height, expectedValues.popupHeight)
Expand Down