Skip to content

Commit

Permalink
Patch 2.7.0
Browse files Browse the repository at this point in the history
feat:
- Added the possibility to resize top and bottom popups using a drag gesture (#118)
  • Loading branch information
FulcrumOne authored Aug 14, 2024
1 parent 70af749 commit eee37c2
Show file tree
Hide file tree
Showing 11 changed files with 300 additions and 34 deletions.
2 changes: 1 addition & 1 deletion MijickPopupView.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Pod::Spec.new do |s|
PopupView is a free and open-source library dedicated for SwiftUI that makes the process of presenting popups easier and much cleaner.
DESC

s.version = '2.6.0'
s.version = '2.7.0'
s.ios.deployment_target = '14.0'
s.osx.deployment_target = '13.0'
s.swift_version = '5.0'
Expand Down
8 changes: 8 additions & 0 deletions Sources/Internal/Extensions/Array++.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import Foundation

// MARK: Mutable
extension Array {
@inlinable mutating func append(_ newElement: Element, if prerequisite: Bool) { if prerequisite { append(newElement) } }
@inlinable mutating func removeAllUpToElement(where predicate: (Element) -> Bool) { if let index = lastIndex(where: predicate) { removeLast(count - index - 1) } }
Expand All @@ -21,6 +22,13 @@ extension Array {
}
}}
}

// MARK: Immutable
extension Array {
@inlinable func appending(_ newElement: Element) -> Self { self + [newElement] }
}

// MARK: Others
extension Array {
var nextToLast: Element? { count >= 2 ? self[count - 2] : nil }
}
6 changes: 5 additions & 1 deletion Sources/Internal/Extensions/View++.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import SwiftUI

// MARK: - Alignments
extension View {
func align(to edge: Edge, _ value: CGFloat?) -> some View { padding(.init(edge), value).frame(maxHeight: value != nil ? .infinity : nil, alignment: edge.toAlignment()) }
func align(to edge: Edge, _ value: CGFloat?) -> some View {
padding(.init(edge), value)
.frame(height: value != nil ? ScreenManager.shared.size.height : nil, alignment: edge.toAlignment())
.frame(maxHeight: value != nil ? .infinity : nil, alignment: edge.toAlignment())
}
}
fileprivate extension Edge {
func toAlignment() -> Alignment {
Expand Down
27 changes: 26 additions & 1 deletion Sources/Internal/Protocols/PopupStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ protocol PopupStack: View {

var items: [AnyPopup<Config>] { get }
var heights: [ID: CGFloat] { get }
var dragHeights: [ID: CGFloat] { get }
var globalConfig: GlobalConfig { get }
var gestureTranslation: CGFloat { get }
var isGestureActive: Bool { get }
Expand All @@ -30,6 +31,7 @@ protocol PopupStack: View {
}
extension PopupStack {
var heights: [ID: CGFloat] { [:] }
var dragHeights: [ID: CGFloat] { [:] }
var gestureTranslation: CGFloat { 0 }
var isGestureActive: Bool { false }
var translationProgress: CGFloat { 1 }
Expand Down Expand Up @@ -101,7 +103,20 @@ extension PopupStack {

// MARK: - Stack Offset
extension PopupStack {
func getOffset(_ item: AnyPopup<Config>) -> CGFloat { isLast(item) ? gestureTranslation : invertedIndex(item).floatValue * stackOffsetValue }
func getOffset(_ item: AnyPopup<Config>) -> CGFloat { switch isLast(item) {
case true: calculateOffsetForLastItem()
case false: calculateOffsetForOtherItems(item)
}}
}
private extension PopupStack {
func calculateOffsetForLastItem() -> CGFloat { switch items {
case _ as [AnyPopup<BottomPopupConfig>]: max(gestureTranslation - getLastDragHeight(), 0)
case _ as [AnyPopup<TopPopupConfig>]: min(gestureTranslation + getLastDragHeight(), 0)
default: 0
}}
func calculateOffsetForOtherItems(_ item: AnyPopup<Config>) -> CGFloat {
invertedIndex(item).floatValue * stackOffsetValue
}
}

// MARK: - Initial Height
Expand All @@ -114,6 +129,16 @@ extension PopupStack {
}
}

// MARK: - Last Popup Height
extension PopupStack {
func getLastPopupHeight() -> CGFloat? { heights[items.last?.id ?? .init()] }
}

// MARK: - Drag Height Value
extension PopupStack {
func getLastDragHeight() -> CGFloat { dragHeights[items.last?.id ?? .init()] ?? 0 }
}

// MARK: - Item ZIndex
extension PopupStack {
func getZIndex(_ item: AnyPopup<Config>) -> Double { .init(items.firstIndex(of: item) ?? 2137) }
Expand Down
144 changes: 124 additions & 20 deletions Sources/Internal/Views/PopupBottomStackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct PopupBottomStackView: PopupStack {
let globalConfig: GlobalConfig
@State var gestureTranslation: CGFloat = 0
@State var heights: [ID: CGFloat] = [:]
@State var dragHeights: [ID: CGFloat] = [:]
@GestureState var isGestureActive: Bool = false
@ObservedObject private var screenManager: ScreenManager = .shared
@ObservedObject private var keyboardManager: KeyboardManager = .shared
Expand Down Expand Up @@ -59,43 +60,130 @@ private extension PopupBottomStackView {
}
}

// MARK: - Gesture
// MARK: - Gestures

// MARK: On Changed
private extension PopupBottomStackView {
func onPopupDragGestureChanged(_ value: CGFloat) { if canDragGestureBeUsed() {
updateGestureTranslation(value)
}}
}
private extension PopupBottomStackView {
func canDragGestureBeUsed() -> Bool { lastPopupConfig.dragGestureEnabled ?? globalConfig.bottom.dragGestureEnabled }
func updateGestureTranslation(_ value: CGFloat) { switch lastPopupConfig.dragDetents.isEmpty {
case true: gestureTranslation = calculateGestureTranslationWhenNoDragDetents(value)
case false: gestureTranslation = calculateGestureTranslationWhenDragDetents(value)
}}
}
private extension PopupBottomStackView {
func calculateGestureTranslationWhenNoDragDetents(_ value: CGFloat) -> CGFloat { max(value, 0) }
func calculateGestureTranslationWhenDragDetents(_ value: CGFloat) -> CGFloat { guard value < 0, let lastPopupHeight = getLastPopupHeight() else { return value }
let maxHeight = calculateMaxHeightForDragGesture(lastPopupHeight)
let dragTranslation = calculateDragTranslation(maxHeight, lastPopupHeight)
return max(dragTranslation, value)
}
}
private extension PopupBottomStackView {
func onPopupDragGestureChanged(_ value: CGFloat) {
if lastPopupConfig.dragGestureEnabled ?? globalConfig.bottom.dragGestureEnabled { gestureTranslation = max(0, value) }
func calculateMaxHeightForDragGesture(_ lastPopupHeight: CGFloat) -> CGFloat {
let maxHeight1 = (calculatePopupTargetHeightsFromDragDetents(lastPopupHeight).max() ?? 0) + dragTranslationThreshold
let maxHeight2 = screenManager.size.height
return min(maxHeight1, maxHeight2)
}
func onPopupDragGestureEnded(_ value: CGFloat) {
func calculateDragTranslation(_ maxHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat {
let translation = maxHeight - lastPopupHeight - getLastDragHeight()
return -translation
}
}
private extension PopupBottomStackView {
var dragTranslationThreshold: CGFloat { 8 }
}

// MARK: On Ended
private extension PopupBottomStackView {
func onPopupDragGestureEnded(_ value: CGFloat) { guard value != 0 else { return }
dismissLastItemIfNeeded()
resetGestureTranslationOnEnd()
updateTranslationValues()
}
}
private extension PopupBottomStackView {
func dismissLastItemIfNeeded() {
if translationProgress >= gestureClosingThresholdFactor { items.last?.remove() }
func dismissLastItemIfNeeded() { if shouldDismissPopup() {
items.last?.remove()
}}
func updateTranslationValues() { if let lastPopupHeight = getLastPopupHeight() {
let currentPopupHeight = calculateCurrentPopupHeight(lastPopupHeight)
let popupTargetHeights = calculatePopupTargetHeightsFromDragDetents(lastPopupHeight)
let targetHeight = calculateTargetPopupHeight(currentPopupHeight, popupTargetHeights)
let targetDragHeight = calculateTargetDragHeight(targetHeight, lastPopupHeight)

resetGestureTranslation()
updateDragHeight(targetDragHeight)
}}
}
private extension PopupBottomStackView {
func calculateCurrentPopupHeight(_ lastPopupHeight: CGFloat) -> CGFloat {
let lastDragHeight = getLastDragHeight()
let currentDragHeight = lastDragHeight - gestureTranslation

let currentPopupHeight = lastPopupHeight + currentDragHeight
return currentPopupHeight
}
func calculatePopupTargetHeightsFromDragDetents(_ lastPopupHeight: CGFloat) -> [CGFloat] { lastPopupConfig.dragDetents
.map { switch $0 {
case .fixed(let targetHeight): min(targetHeight, getMaxHeight())
case .fraction(let fraction): min(fraction * lastPopupHeight, getMaxHeight())
case .fullscreen(let stackVisible): stackVisible ? getMaxHeight() : screenManager.size.height
}}
.appending(lastPopupHeight)
.sorted(by: <)
}
func calculateTargetPopupHeight(_ currentPopupHeight: CGFloat, _ popupTargetHeights: [CGFloat]) -> CGFloat {
guard let lastPopupHeight = getLastPopupHeight(),
currentPopupHeight < screenManager.size.height
else { return popupTargetHeights.last ?? 0 }

let initialIndex = popupTargetHeights.firstIndex(where: { $0 >= currentPopupHeight }) ?? popupTargetHeights.count - 1,
targetIndex = gestureTranslation <= 0 ? initialIndex : max(0, initialIndex - 1)
let previousPopupHeight = getLastDragHeight() + lastPopupHeight,
popupTargetHeight = popupTargetHeights[targetIndex],
deltaHeight = abs(previousPopupHeight - popupTargetHeight)
let progress = abs(currentPopupHeight - previousPopupHeight) / deltaHeight

if progress < gestureClosingThresholdFactor {
let index = gestureTranslation <= 0 ? max(0, initialIndex - 1) : initialIndex
return popupTargetHeights[index]
}
return popupTargetHeights[targetIndex]
}
func calculateTargetDragHeight(_ targetHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat {
targetHeight - lastPopupHeight
}
func resetGestureTranslationOnEnd() {
let resetAfter = items.count == 1 && translationProgress >= gestureClosingThresholdFactor ? 0.25 : 0
func updateDragHeight(_ targetDragHeight: CGFloat) { if let id = items.last?.id {
dragHeights[id] = targetDragHeight
}}
func resetGestureTranslation() {
let resetAfter = items.count == 1 && shouldDismissPopup() ? 0.25 : 0
DispatchQueue.main.asyncAfter(deadline: .now() + resetAfter) { gestureTranslation = 0 }
}
func shouldDismissPopup() -> Bool {
translationProgress >= gestureClosingThresholdFactor
}
}

// MARK: - View Modifiers
private extension PopupBottomStackView {
func getCorners() -> RectCorner {
switch popupBottomPadding {
case 0: return [.topLeft, .topRight]
default: return .allCorners
}
}
func getCorners() -> RectCorner { switch popupBottomPadding {
case 0: return [.topLeft, .topRight]
default: return .allCorners
}}
func saveHeight(_ height: CGFloat, for item: AnyPopup<BottomPopupConfig>) { if !isGestureActive {
let config = item.configurePopup(popup: .init())

if config.contentFillsEntireScreen { return heights[item.id] = screenManager.size.height + screenManager.safeArea.top }
if config.contentFillsEntireScreen { return heights[item.id] = screenManager.size.height }
if config.contentFillsWholeHeight { return heights[item.id] = getMaxHeight() }
return heights[item.id] = min(height, maxHeight)
}}
func getMaxHeight() -> CGFloat {
let basicHeight = screenManager.size.height - screenManager.safeArea.top
let basicHeight = screenManager.size.height - screenManager.safeArea.top - popupBottomPadding
let stackedViewsCount = min(max(0, globalConfig.bottom.stackLimit - 1), items.count - 1)
let stackedViewsHeight = globalConfig.bottom.stackOffset * .init(stackedViewsCount) * maxHeightStackedFactor
return basicHeight - stackedViewsHeight
Expand All @@ -106,7 +194,13 @@ private extension PopupBottomStackView {

return max(screenManager.safeArea.bottom - popupBottomPadding, 0)
}
func getContentTopPadding() -> CGFloat { lastPopupConfig.contentFillsEntireScreen && !lastPopupConfig.contentIgnoresSafeArea ? screenManager.safeArea.top : 0 }
func getContentTopPadding() -> CGFloat {
if lastPopupConfig.contentIgnoresSafeArea { return 0 }

let heightWithoutTopSafeArea = screenManager.size.height - screenManager.safeArea.top
let topPadding = height - heightWithoutTopSafeArea
return max(topPadding, 0)
}
func getHeight(_ item: AnyPopup<BottomPopupConfig>) -> CGFloat? { getConfig(item).contentFillsEntireScreen ? nil : height }
func getFixedSize(_ item: AnyPopup<BottomPopupConfig>) -> Bool { !(getConfig(item).contentFillsEntireScreen || getConfig(item).contentFillsWholeHeight || height == maxHeight) }
func getBackgroundColour(for item: AnyPopup<BottomPopupConfig>) -> Color { item.configurePopup(popup: .init()).backgroundColour ?? globalConfig.bottom.backgroundColour }
Expand All @@ -117,7 +211,17 @@ extension PopupBottomStackView {
var popupBottomPadding: CGFloat { lastPopupConfig.popupPadding.bottom }
var popupHorizontalPadding: CGFloat { lastPopupConfig.popupPadding.horizontal }
var popupShadow: Shadow { globalConfig.bottom.shadow }
var height: CGFloat { heights.first { $0.key == items.last?.id }?.value ?? (lastPopupConfig.contentFillsEntireScreen ? screenManager.size.height : getInitialHeight()) }
var height: CGFloat {
let lastDragHeight = getLastDragHeight(),
lastPopupHeight = getLastPopupHeight() ?? (lastPopupConfig.contentFillsEntireScreen ? screenManager.size.height : getInitialHeight())
let dragTranslation = lastPopupHeight + lastDragHeight - gestureTranslation
let newHeight = max(lastPopupHeight, dragTranslation)

switch lastPopupHeight + lastDragHeight > screenManager.size.height && !lastPopupConfig.contentIgnoresSafeArea {
case true: return newHeight == screenManager.size.height ? newHeight : newHeight - screenManager.safeArea.top
case false: return newHeight
}
}
var maxHeight: CGFloat { getMaxHeight() - popupBottomPadding }
var distanceFromKeyboard: CGFloat { lastPopupConfig.distanceFromKeyboard ?? globalConfig.bottom.distanceFromKeyboard }
var cornerRadius: CGFloat { let cornerRadius = lastPopupConfig.cornerRadius ?? globalConfig.bottom.cornerRadius; return lastPopupConfig.contentFillsEntireScreen ? min(cornerRadius, screenManager.cornerRadius ?? 0) : cornerRadius }
Expand All @@ -129,7 +233,7 @@ extension PopupBottomStackView {
var stackOffsetValue: CGFloat { -globalConfig.bottom.stackOffset }
var stackCornerRadiusMultiplier: CGFloat { globalConfig.bottom.stackCornerRadiusMultiplier }

var translationProgress: CGFloat { abs(gestureTranslation) / height }
var translationProgress: CGFloat { guard let popupHeight = getLastPopupHeight() else { return 0 }; return max(gestureTranslation - getLastDragHeight(), 0) / popupHeight }
var gestureClosingThresholdFactor: CGFloat { globalConfig.bottom.dragGestureProgressToClose }
var transition: AnyTransition { .move(edge: .bottom) }

Expand Down
Loading

0 comments on commit eee37c2

Please sign in to comment.