initial
This commit is contained in:
92
Pods/SwiftEntryKit/Source/Infra/EKBackgroundView.swift
generated
Normal file
92
Pods/SwiftEntryKit/Source/Infra/EKBackgroundView.swift
generated
Normal file
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// EKBackgroundView.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/20/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class EKBackgroundView: EKStyleView {
|
||||
|
||||
struct Style {
|
||||
let background: EKAttributes.BackgroundStyle
|
||||
let displayMode: EKAttributes.DisplayMode
|
||||
}
|
||||
|
||||
// MARK: Props
|
||||
private let visualEffectView: UIVisualEffectView
|
||||
private let imageView: UIImageView
|
||||
private let gradientView: GradientView
|
||||
|
||||
// MARK: Setup
|
||||
init() {
|
||||
imageView = UIImageView()
|
||||
visualEffectView = UIVisualEffectView(effect: nil)
|
||||
gradientView = GradientView()
|
||||
super.init(frame: UIScreen.main.bounds)
|
||||
|
||||
addSubview(imageView)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.fillSuperview()
|
||||
|
||||
addSubview(visualEffectView)
|
||||
visualEffectView.fillSuperview()
|
||||
|
||||
addSubview(gradientView)
|
||||
gradientView.fillSuperview()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// Background setter
|
||||
var style: Style! {
|
||||
didSet {
|
||||
guard let style = style else {
|
||||
return
|
||||
}
|
||||
var gradient: EKAttributes.BackgroundStyle.Gradient?
|
||||
var backgroundEffect: UIBlurEffect?
|
||||
var backgroundColor: UIColor = .clear
|
||||
var backgroundImage: UIImage?
|
||||
|
||||
switch style.background {
|
||||
case .color(color: let color):
|
||||
backgroundColor = color.color(for: traitCollection,
|
||||
mode: style.displayMode)
|
||||
case .gradient(gradient: let value):
|
||||
gradient = value
|
||||
case .image(image: let image):
|
||||
backgroundImage = image
|
||||
case .visualEffect(style: let value):
|
||||
backgroundEffect = value.blurEffect(for: traitCollection,
|
||||
mode: style.displayMode)
|
||||
case .clear:
|
||||
break
|
||||
}
|
||||
|
||||
gradientView.style = GradientView.Style(gradient: gradient,
|
||||
displayMode: style.displayMode)
|
||||
visualEffectView.effect = backgroundEffect
|
||||
layer.backgroundColor = backgroundColor.cgColor
|
||||
imageView.image = backgroundImage
|
||||
}
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
guard let style = style else { return }
|
||||
switch style.background {
|
||||
case .color(color: let color):
|
||||
layer.backgroundColor = color.color(for: traitCollection,
|
||||
mode: style.displayMode).cgColor
|
||||
case .visualEffect(style: let value):
|
||||
visualEffectView.effect = value.blurEffect(for: traitCollection,
|
||||
mode: style.displayMode)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
752
Pods/SwiftEntryKit/Source/Infra/EKContentView.swift
generated
Normal file
752
Pods/SwiftEntryKit/Source/Infra/EKContentView.swift
generated
Normal file
@@ -0,0 +1,752 @@
|
||||
//
|
||||
// EKScrollView.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/19/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol EntryContentViewDelegate: AnyObject {
|
||||
func changeToActive(withAttributes attributes: EKAttributes)
|
||||
func changeToInactive(withAttributes attributes: EKAttributes, pushOut: Bool)
|
||||
func didFinishDisplaying(entry: EKEntryView, keepWindowActive: Bool, dismissCompletionHandler: SwiftEntryKit.DismissCompletionHandler?)
|
||||
}
|
||||
|
||||
class EKContentView: UIView {
|
||||
|
||||
enum OutTranslation {
|
||||
case exit
|
||||
case pop
|
||||
case swipeDown
|
||||
case swipeUp
|
||||
}
|
||||
|
||||
struct OutTranslationAnchor {
|
||||
var messageOut: QLAttribute
|
||||
var screenOut: QLAttribute
|
||||
|
||||
init(_ messageOut: QLAttribute, to screenOut: QLAttribute) {
|
||||
self.messageOut = messageOut
|
||||
self.screenOut = screenOut
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Props
|
||||
|
||||
// Entry delegate
|
||||
private weak var entryDelegate: EntryContentViewDelegate!
|
||||
|
||||
// Constraints and Offsets
|
||||
private var entranceOutConstraint: NSLayoutConstraint!
|
||||
private var exitOutConstraint: NSLayoutConstraint!
|
||||
private var swipeDownOutConstraint: NSLayoutConstraint!
|
||||
private var swipeUpOutConstraint: NSLayoutConstraint!
|
||||
private var popOutConstraint: NSLayoutConstraint!
|
||||
private var inConstraint: NSLayoutConstraint!
|
||||
private var resistanceConstraint: NSLayoutConstraint!
|
||||
private var inKeyboardConstraint: NSLayoutConstraint!
|
||||
|
||||
private var inOffset: CGFloat = 0
|
||||
private var totalTranslation: CGFloat = 0
|
||||
private var verticalLimit: CGFloat = 0
|
||||
private let swipeMinVelocity: CGFloat = 60
|
||||
|
||||
private var outDispatchWorkItem: DispatchWorkItem!
|
||||
|
||||
private var keyboardState = KeyboardState.hidden
|
||||
|
||||
// Dismissal handler
|
||||
var dismissHandler: SwiftEntryKit.DismissCompletionHandler?
|
||||
|
||||
// Data source
|
||||
private var attributes: EKAttributes {
|
||||
return contentView.attributes
|
||||
}
|
||||
|
||||
// Content
|
||||
private var contentView: EKEntryView!
|
||||
|
||||
// MARK: Setup
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
init(withEntryDelegate entryDelegate: EntryContentViewDelegate) {
|
||||
self.entryDelegate = entryDelegate
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
// Called from outer scope with a presentable view and attributes
|
||||
func setup(with contentView: EKEntryView) {
|
||||
|
||||
self.contentView = contentView
|
||||
|
||||
// Execute willAppear lifecycle action if needed
|
||||
contentView.attributes.lifecycleEvents.willAppear?()
|
||||
|
||||
// Setup attributes
|
||||
setupAttributes()
|
||||
|
||||
// Setup initial position
|
||||
setupInitialPosition()
|
||||
|
||||
// Setup width, height and maximum width
|
||||
setupLayoutConstraints()
|
||||
|
||||
// Animate in
|
||||
animateIn()
|
||||
|
||||
// Setup tap gesture
|
||||
setupTapGestureRecognizer()
|
||||
|
||||
// Generate haptic feedback
|
||||
generateHapticFeedback()
|
||||
|
||||
setupKeyboardChangeIfNeeded()
|
||||
}
|
||||
|
||||
// Setup the scrollView initial position
|
||||
private func setupInitialPosition() {
|
||||
|
||||
// Determine the layout entrance type according to the entry type
|
||||
let messageInAnchor: NSLayoutConstraint.Attribute
|
||||
|
||||
inOffset = 0
|
||||
|
||||
var totalEntryHeight: CGFloat = 0
|
||||
|
||||
// Define a spacer to catch top / bottom offsets
|
||||
var spacerView: UIView!
|
||||
let safeAreaInsets = EKWindowProvider.safeAreaInsets
|
||||
let overrideSafeArea = attributes.positionConstraints.safeArea.isOverridden
|
||||
|
||||
if !overrideSafeArea && safeAreaInsets.hasVerticalInsets && !attributes.position.isCenter {
|
||||
spacerView = UIView()
|
||||
addSubview(spacerView)
|
||||
spacerView.set(.height, of: safeAreaInsets.top)
|
||||
spacerView.layoutToSuperview(.width, .centerX)
|
||||
|
||||
totalEntryHeight += safeAreaInsets.top
|
||||
}
|
||||
|
||||
switch attributes.position {
|
||||
case .top:
|
||||
messageInAnchor = .top
|
||||
inOffset = overrideSafeArea ? 0 : safeAreaInsets.top
|
||||
inOffset += attributes.positionConstraints.verticalOffset
|
||||
spacerView?.layout(.bottom, to: .top, of: self)
|
||||
case .bottom:
|
||||
messageInAnchor = .bottom
|
||||
inOffset = overrideSafeArea ? 0 : -safeAreaInsets.bottom
|
||||
inOffset -= attributes.positionConstraints.verticalOffset
|
||||
spacerView?.layout(.top, to: .bottom, of: self)
|
||||
case .center:
|
||||
messageInAnchor = .centerY
|
||||
}
|
||||
|
||||
// Layout the content view inside the scroll view
|
||||
addSubview(contentView)
|
||||
contentView.layoutToSuperview(.left, .right, .top, .bottom)
|
||||
contentView.layoutToSuperview(.width, .height)
|
||||
|
||||
inConstraint = layout(to: messageInAnchor, of: superview!, offset: inOffset, priority: .defaultLow)
|
||||
|
||||
// Set position constraints
|
||||
setupOutConstraints(messageInAnchor: messageInAnchor)
|
||||
|
||||
totalTranslation = inOffset
|
||||
switch attributes.position {
|
||||
case .top:
|
||||
verticalLimit = inOffset
|
||||
case .bottom, .center:
|
||||
verticalLimit = UIScreen.main.bounds.height + inOffset
|
||||
}
|
||||
|
||||
// Setup keyboard constraints
|
||||
switch attributes.positionConstraints.keyboardRelation {
|
||||
case .bind(offset: let offset):
|
||||
if let screenEdgeResistance = offset.screenEdgeResistance {
|
||||
resistanceConstraint = layoutToSuperview(.top, relation: .greaterThanOrEqual, offset: screenEdgeResistance, priority: .defaultLow)
|
||||
}
|
||||
inKeyboardConstraint = layoutToSuperview(.bottom, priority: .defaultLow)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func setupOutConstraint(animation: EKAttributes.Animation?, messageInAnchor: QLAttribute, priority: QLPriority) -> NSLayoutConstraint {
|
||||
let constraint: NSLayoutConstraint
|
||||
if let translation = animation?.translate {
|
||||
var anchor: OutTranslationAnchor
|
||||
switch translation.anchorPosition {
|
||||
case .top:
|
||||
anchor = OutTranslationAnchor(.bottom, to: .top)
|
||||
case .bottom:
|
||||
anchor = OutTranslationAnchor(.top, to: .bottom)
|
||||
case .automatic where attributes.position.isTop:
|
||||
anchor = OutTranslationAnchor(.bottom, to: .top)
|
||||
case .automatic: // attributes.position.isBottom:
|
||||
anchor = OutTranslationAnchor(.top, to: .bottom)
|
||||
}
|
||||
constraint = layout(anchor.messageOut, to: anchor.screenOut, of: superview!, priority: priority)!
|
||||
} else {
|
||||
constraint = layout(to: messageInAnchor, of: superview!, offset: inOffset, priority: priority)!
|
||||
}
|
||||
return constraint
|
||||
}
|
||||
|
||||
// Setup out constraints - taking into account the full picture and all the possible use-cases
|
||||
private func setupOutConstraints(messageInAnchor: QLAttribute) {
|
||||
|
||||
// Setup entrance and exit out constraints
|
||||
entranceOutConstraint = setupOutConstraint(animation: attributes.entranceAnimation, messageInAnchor: messageInAnchor, priority: .must)
|
||||
exitOutConstraint = setupOutConstraint(animation: attributes.exitAnimation, messageInAnchor: messageInAnchor, priority: .defaultLow)
|
||||
swipeDownOutConstraint = layout(.top, to: .bottom, of: superview!, priority: .defaultLow)!
|
||||
swipeUpOutConstraint = layout(.bottom, to: .top, of: superview!, priority: .defaultLow)!
|
||||
|
||||
// Setup pop out constraint
|
||||
var popAnimation: EKAttributes.Animation?
|
||||
if case .animated(animation: let animation) = attributes.popBehavior {
|
||||
popAnimation = animation
|
||||
}
|
||||
popOutConstraint = setupOutConstraint(animation: popAnimation, messageInAnchor: messageInAnchor, priority: .defaultLow)
|
||||
}
|
||||
|
||||
|
||||
private func setupSize() {
|
||||
|
||||
// Layout the scroll view horizontally inside the screen
|
||||
switch attributes.positionConstraints.size.width {
|
||||
case .offset(value: let offset):
|
||||
layoutToSuperview(axis: .horizontally, offset: offset, priority: .must)
|
||||
case .ratio(value: let ratio):
|
||||
layoutToSuperview(.width, ratio: ratio, priority: .must)
|
||||
case .constant(value: let constant):
|
||||
set(.width, of: constant, priority: .must)
|
||||
case .intrinsic:
|
||||
break
|
||||
}
|
||||
|
||||
// Layout the scroll view vertically inside the screen
|
||||
switch attributes.positionConstraints.size.height {
|
||||
case .offset(value: let offset):
|
||||
layoutToSuperview(.height, offset: -offset * 2, priority: .must)
|
||||
case .ratio(value: let ratio):
|
||||
layoutToSuperview(.height, ratio: ratio, priority: .must)
|
||||
case .constant(value: let constant):
|
||||
set(.height, of: constant, priority: .must)
|
||||
case .intrinsic:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func setupMaxSize() {
|
||||
|
||||
// Layout the scroll view according to the maximum width (if given any)
|
||||
switch attributes.positionConstraints.maxSize.width {
|
||||
case .offset(value: let offset):
|
||||
layout(to: .left, of: superview!, relation: .greaterThanOrEqual, offset: offset)
|
||||
layout(to: .right, of: superview!, relation: .lessThanOrEqual, offset: -offset)
|
||||
case .ratio(value: let ratio):
|
||||
layoutToSuperview(.centerX)
|
||||
layout(to: .width, of: superview!, relation: .lessThanOrEqual, ratio: ratio)
|
||||
case .constant(value: let constant):
|
||||
set(.width, of: constant, relation: .lessThanOrEqual)
|
||||
break
|
||||
case .intrinsic:
|
||||
break
|
||||
}
|
||||
|
||||
// Layout the scroll view according to the maximum width (if given any)
|
||||
switch attributes.positionConstraints.maxSize.height {
|
||||
case .offset(value: let offset):
|
||||
layout(to: .height, of: superview!, relation: .lessThanOrEqual, offset: -offset * 2)
|
||||
case .ratio(value: let ratio):
|
||||
layout(to: .height, of: superview!, relation: .lessThanOrEqual, ratio: ratio)
|
||||
case .constant(value: let constant):
|
||||
set(.height, of: constant, relation: .lessThanOrEqual)
|
||||
break
|
||||
case .intrinsic:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Setup layout constraints according to EKAttributes.PositionConstraints
|
||||
private func setupLayoutConstraints() {
|
||||
layoutToSuperview(.centerX)
|
||||
setupSize()
|
||||
setupMaxSize()
|
||||
}
|
||||
|
||||
// Setup general attributes
|
||||
private func setupAttributes() {
|
||||
clipsToBounds = false
|
||||
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized(gr:)))
|
||||
panGestureRecognizer.isEnabled = attributes.scroll.isEnabled
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// Setup tap gesture
|
||||
private func setupTapGestureRecognizer() {
|
||||
switch attributes.entryInteraction.defaultAction {
|
||||
case .forward:
|
||||
return
|
||||
default:
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
tapGestureRecognizer.cancelsTouchesInView = false
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a haptic feedback if needed
|
||||
private func generateHapticFeedback() {
|
||||
guard #available(iOS 10.0, *) else {
|
||||
return
|
||||
}
|
||||
HapticFeedbackGenerator.notification(type: attributes.hapticFeedbackType)
|
||||
}
|
||||
|
||||
// MARK: Animations
|
||||
|
||||
// Schedule out animation
|
||||
private func scheduleAnimateOut(withDelay delay: TimeInterval? = nil) {
|
||||
outDispatchWorkItem?.cancel()
|
||||
outDispatchWorkItem = DispatchWorkItem { [weak self] in
|
||||
self?.animateOut(pushOut: false)
|
||||
}
|
||||
let delay = attributes.entranceAnimation.totalDuration + (delay ?? attributes.displayDuration)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: outDispatchWorkItem)
|
||||
}
|
||||
|
||||
// Animate out
|
||||
func animateOut(pushOut: Bool) {
|
||||
|
||||
// Execute willDisappear action if needed
|
||||
contentView.attributes.lifecycleEvents.willDisappear?()
|
||||
|
||||
if attributes.positionConstraints.keyboardRelation.isBound {
|
||||
endEditing(true)
|
||||
}
|
||||
|
||||
outDispatchWorkItem?.cancel()
|
||||
entryDelegate?.changeToInactive(withAttributes: attributes, pushOut: pushOut)
|
||||
|
||||
if case .animated(animation: let animation) = attributes.popBehavior, pushOut {
|
||||
animateOut(with: animation, outTranslationType: .pop)
|
||||
} else {
|
||||
animateOut(with: attributes.exitAnimation, outTranslationType: .exit)
|
||||
}
|
||||
}
|
||||
|
||||
// Animate out
|
||||
private func animateOut(with animation: EKAttributes.Animation, outTranslationType: OutTranslation) {
|
||||
|
||||
superview?.layoutIfNeeded()
|
||||
|
||||
if let translation = animation.translate {
|
||||
performAnimation(out: true, with: translation) { [weak self] in
|
||||
self?.translateOut(withType: outTranslationType)
|
||||
}
|
||||
}
|
||||
|
||||
if let fade = animation.fade {
|
||||
performAnimation(out: true, with: fade, preAction: { self.alpha = fade.start }) {
|
||||
self.alpha = fade.end
|
||||
}
|
||||
}
|
||||
|
||||
if let scale = animation.scale {
|
||||
performAnimation(out: true, with: scale, preAction: { self.transform = CGAffineTransform(scaleX: scale.start, y: scale.start) }) {
|
||||
self.transform = CGAffineTransform(scaleX: scale.end, y: scale.end)
|
||||
}
|
||||
}
|
||||
|
||||
if animation.containsAnimation {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animation.maxDuration) {
|
||||
self.removeFromSuperview(keepWindow: false)
|
||||
}
|
||||
} else {
|
||||
translateOut(withType: outTranslationType)
|
||||
removeFromSuperview(keepWindow: false)
|
||||
}
|
||||
}
|
||||
|
||||
// Animate in
|
||||
private func animateIn() {
|
||||
|
||||
let animation = attributes.entranceAnimation
|
||||
|
||||
superview?.layoutIfNeeded()
|
||||
|
||||
if let translation = animation.translate {
|
||||
performAnimation(out: false, with: translation, action: translateIn)
|
||||
} else {
|
||||
translateIn()
|
||||
}
|
||||
|
||||
if let fade = animation.fade {
|
||||
performAnimation(out: false, with: fade, preAction: { self.alpha = fade.start }) {
|
||||
self.alpha = fade.end
|
||||
}
|
||||
}
|
||||
|
||||
if let scale = animation.scale {
|
||||
performAnimation(out: false, with: scale, preAction: { self.transform = CGAffineTransform(scaleX: scale.start, y: scale.start) }) {
|
||||
self.transform = CGAffineTransform(scaleX: scale.end, y: scale.end)
|
||||
}
|
||||
}
|
||||
|
||||
entryDelegate?.changeToActive(withAttributes: attributes)
|
||||
|
||||
// Execute didAppear action if needed
|
||||
if animation.containsAnimation {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animation.maxDuration) {
|
||||
self.contentView.attributes.lifecycleEvents.didAppear?()
|
||||
}
|
||||
} else {
|
||||
contentView.attributes.lifecycleEvents.didAppear?()
|
||||
}
|
||||
|
||||
scheduleAnimateOut()
|
||||
}
|
||||
|
||||
// Translate in
|
||||
private func translateIn() {
|
||||
entranceOutConstraint.priority = .defaultLow
|
||||
exitOutConstraint.priority = .defaultLow
|
||||
popOutConstraint.priority = .defaultLow
|
||||
inConstraint.priority = .must
|
||||
superview?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// Translate out
|
||||
private func translateOut(withType type: OutTranslation) {
|
||||
inConstraint.priority = .defaultLow
|
||||
entranceOutConstraint.priority = .defaultLow
|
||||
switch type {
|
||||
case .exit:
|
||||
exitOutConstraint.priority = .must
|
||||
case .pop:
|
||||
popOutConstraint.priority = .must
|
||||
case .swipeUp:
|
||||
swipeUpOutConstraint.priority = .must
|
||||
case .swipeDown:
|
||||
swipeDownOutConstraint.priority = .must
|
||||
}
|
||||
superview?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// Perform animation - translate / scale / fade
|
||||
private func performAnimation(out: Bool, with animation: EKAnimation, preAction: @escaping () -> () = {}, action: @escaping () -> ()) {
|
||||
let curve: UIView.AnimationOptions = out ? .curveEaseIn : .curveEaseOut
|
||||
let options: UIView.AnimationOptions = [curve, .beginFromCurrentState]
|
||||
preAction()
|
||||
if let spring = animation.spring {
|
||||
UIView.animate(withDuration: animation.duration, delay: animation.delay, usingSpringWithDamping: spring.damping, initialSpringVelocity: spring.initialVelocity, options: options, animations: {
|
||||
action()
|
||||
}, completion: nil)
|
||||
} else {
|
||||
UIView.animate(withDuration: animation.duration, delay: animation.delay, options: options, animations: {
|
||||
action()
|
||||
}, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Remvoe entry
|
||||
|
||||
// Removes the view promptly - DOES NOT animate out
|
||||
func removePromptly(keepWindow: Bool = true) {
|
||||
outDispatchWorkItem?.cancel()
|
||||
entryDelegate?.changeToInactive(withAttributes: attributes, pushOut: false)
|
||||
contentView.content.attributes.lifecycleEvents.willDisappear?()
|
||||
removeFromSuperview(keepWindow: keepWindow)
|
||||
}
|
||||
|
||||
// Remove self from superview
|
||||
func removeFromSuperview(keepWindow: Bool) {
|
||||
guard superview != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute didDisappear action if needed
|
||||
let didDisappear = contentView.content.attributes.lifecycleEvents.didDisappear
|
||||
|
||||
// Remove the view from its superview and in a case of a view controller, from its parent controller.
|
||||
super.removeFromSuperview()
|
||||
contentView.content.viewController?.removeFromParent()
|
||||
|
||||
entryDelegate.didFinishDisplaying(entry: contentView, keepWindowActive: keepWindow, dismissCompletionHandler: dismissHandler)
|
||||
|
||||
// Lastly, perform the Dismiss Completion Handler as the entry is no longer displayed
|
||||
didDisappear?()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Keyboard Logic
|
||||
extension EKContentView {
|
||||
|
||||
private enum KeyboardState {
|
||||
case visible
|
||||
case hidden
|
||||
|
||||
var isVisible: Bool {
|
||||
return self == .visible
|
||||
}
|
||||
|
||||
var isHidden: Bool {
|
||||
return self == .hidden
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardAttributes {
|
||||
let duration: TimeInterval
|
||||
let curve: UIView.AnimationOptions
|
||||
let begin: CGRect
|
||||
let end: CGRect
|
||||
|
||||
init?(withRawValue rawValue: [AnyHashable: Any]?) {
|
||||
guard let rawValue = rawValue else {
|
||||
return nil
|
||||
}
|
||||
duration = rawValue[UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval
|
||||
curve = .init(rawValue: rawValue[UIResponder.keyboardAnimationCurveUserInfoKey] as! UInt)
|
||||
begin = (rawValue[UIResponder.keyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
|
||||
end = (rawValue[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
return end.maxY - end.minY
|
||||
}
|
||||
}
|
||||
|
||||
private func setupKeyboardChangeIfNeeded() {
|
||||
guard attributes.positionConstraints.keyboardRelation.isBound else {
|
||||
return
|
||||
}
|
||||
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
}
|
||||
|
||||
private func animate(by userInfo: [AnyHashable: Any]?, entrance: Bool) {
|
||||
|
||||
// Guard that the entry is bound to the keyboard
|
||||
guard case .bind(offset: let offset) = attributes.positionConstraints.keyboardRelation else {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert the user info into keyboard attributes
|
||||
guard let keyboardAtts = KeyboardAttributes(withRawValue: userInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
if entrance {
|
||||
inKeyboardConstraint.constant = -(keyboardAtts.height + offset.bottom)
|
||||
inKeyboardConstraint.priority = .must
|
||||
resistanceConstraint?.priority = .must
|
||||
inConstraint.priority = .defaultLow
|
||||
} else {
|
||||
inKeyboardConstraint.priority = .defaultLow
|
||||
resistanceConstraint?.priority = .defaultLow
|
||||
inConstraint.priority = .must
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: keyboardAtts.duration, delay: 0, options: keyboardAtts.curve, animations: {
|
||||
self.superview?.layoutIfNeeded()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
guard containsFirstResponder else {
|
||||
return
|
||||
}
|
||||
keyboardState = .visible
|
||||
animate(by: notification.userInfo, entrance: true)
|
||||
}
|
||||
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
animate(by: notification.userInfo, entrance: false)
|
||||
}
|
||||
|
||||
@objc func keyboardDidHide(_ notification: Notification) {
|
||||
keyboardState = .hidden
|
||||
}
|
||||
|
||||
@objc func keyboardWillChangeFrame(_ notification: Notification) {
|
||||
guard containsFirstResponder else {
|
||||
return
|
||||
}
|
||||
animate(by: notification.userInfo, entrance: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Responds to user interactions (tap / pan / swipe / touches)
|
||||
extension EKContentView {
|
||||
|
||||
// Tap gesture handler
|
||||
@objc func tapGestureRecognized() {
|
||||
switch attributes.entryInteraction.defaultAction {
|
||||
case .delayExit(by: _) where attributes.displayDuration.isFinite:
|
||||
scheduleAnimateOut()
|
||||
case .dismissEntry:
|
||||
animateOut(pushOut: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
attributes.entryInteraction.customTapActions.forEach { $0() }
|
||||
}
|
||||
|
||||
// Pan gesture handler
|
||||
@objc func panGestureRecognized(gr: UIPanGestureRecognizer) {
|
||||
guard keyboardState.isHidden else {
|
||||
return
|
||||
}
|
||||
|
||||
// Delay the exit of the entry if needed
|
||||
handleExitDelayIfNeeded(byPanState: gr.state)
|
||||
|
||||
let translation = gr.translation(in: superview!).y
|
||||
|
||||
if shouldStretch(with: translation) {
|
||||
if attributes.scroll.isEdgeCrossingEnabled {
|
||||
totalTranslation += translation
|
||||
calculateLogarithmicOffset(forOffset: totalTranslation, currentTranslation: translation)
|
||||
|
||||
switch gr.state {
|
||||
case .ended, .failed, .cancelled:
|
||||
animateRubberBandPullback()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch gr.state {
|
||||
case .ended, .failed, .cancelled:
|
||||
let velocity = gr.velocity(in: superview!).y
|
||||
swipeEnded(withVelocity: velocity)
|
||||
case .changed:
|
||||
inConstraint.constant += translation
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
gr.setTranslation(.zero, in: superview!)
|
||||
}
|
||||
|
||||
private func swipeEnded(withVelocity velocity: CGFloat) {
|
||||
let distance = Swift.abs(inOffset - inConstraint.constant)
|
||||
var duration = max(0.3, TimeInterval(distance / Swift.abs(velocity)))
|
||||
duration = min(0.7, duration)
|
||||
|
||||
if attributes.scroll.isSwipeable && testSwipeVelocity(with: velocity) && testSwipeInConstraint() {
|
||||
stretchOut(usingSwipe: velocity > 0 ? .swipeDown : .swipeUp, duration: duration)
|
||||
} else {
|
||||
animateRubberBandPullback()
|
||||
}
|
||||
}
|
||||
|
||||
private func stretchOut(usingSwipe type: OutTranslation, duration: TimeInterval) {
|
||||
outDispatchWorkItem?.cancel()
|
||||
entryDelegate?.changeToInactive(withAttributes: attributes, pushOut: false)
|
||||
contentView.content.attributes.lifecycleEvents.willDisappear?()
|
||||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 4, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
|
||||
self.translateOut(withType: type)
|
||||
}, completion: { finished in
|
||||
self.removeFromSuperview(keepWindow: false)
|
||||
})
|
||||
}
|
||||
|
||||
private func calculateLogarithmicOffset(forOffset offset: CGFloat, currentTranslation: CGFloat) {
|
||||
guard verticalLimit != 0 else { return }
|
||||
if attributes.position.isTop {
|
||||
inConstraint.constant = verticalLimit * (1 + log10(offset / verticalLimit))
|
||||
} else {
|
||||
let offset = Swift.abs(offset) + verticalLimit
|
||||
let addition: CGFloat = abs(currentTranslation) < 2 ? 0 : 1
|
||||
inConstraint.constant -= (addition + log10(offset / verticalLimit))
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldStretch(with translation: CGFloat) -> Bool {
|
||||
if attributes.position.isTop {
|
||||
return translation > 0 && inConstraint.constant >= inOffset
|
||||
} else {
|
||||
return translation < 0 && inConstraint.constant <= inOffset
|
||||
}
|
||||
}
|
||||
|
||||
private func animateRubberBandPullback() {
|
||||
totalTranslation = verticalLimit
|
||||
|
||||
let animation: EKAttributes.Scroll.PullbackAnimation
|
||||
if case .enabled(swipeable: _, pullbackAnimation: let pullbackAnimation) = attributes.scroll {
|
||||
animation = pullbackAnimation
|
||||
} else {
|
||||
animation = .easeOut
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: animation.duration, delay: 0, usingSpringWithDamping: animation.damping, initialSpringVelocity: animation.initialSpringVelocity, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
|
||||
self.inConstraint?.constant = self.inOffset
|
||||
self.superview?.layoutIfNeeded()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
private func testSwipeInConstraint() -> Bool {
|
||||
if attributes.position.isTop {
|
||||
return inConstraint.constant < inOffset
|
||||
} else {
|
||||
return inConstraint.constant > inOffset
|
||||
}
|
||||
}
|
||||
|
||||
private func testSwipeVelocity(with velocity: CGFloat) -> Bool {
|
||||
if attributes.position.isTop {
|
||||
return velocity < -swipeMinVelocity
|
||||
} else {
|
||||
return velocity > swipeMinVelocity
|
||||
}
|
||||
}
|
||||
|
||||
private func handleExitDelayIfNeeded(byPanState state: UIGestureRecognizer.State) {
|
||||
guard attributes.entryInteraction.isDelayExit && attributes.displayDuration.isFinite else {
|
||||
return
|
||||
}
|
||||
switch state {
|
||||
case .began:
|
||||
outDispatchWorkItem?.cancel()
|
||||
case .ended, .failed, .cancelled:
|
||||
scheduleAnimateOut()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIResponder
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if attributes.entryInteraction.isDelayExit && attributes.displayDuration.isFinite {
|
||||
outDispatchWorkItem?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if attributes.entryInteraction.isDelayExit && attributes.displayDuration.isFinite {
|
||||
scheduleAnimateOut()
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
touchesEnded(touches, with: event)
|
||||
}
|
||||
}
|
||||
182
Pods/SwiftEntryKit/Source/Infra/EKEntryView.swift
generated
Normal file
182
Pods/SwiftEntryKit/Source/Infra/EKEntryView.swift
generated
Normal file
@@ -0,0 +1,182 @@
|
||||
//
|
||||
// EKEntryView.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/15/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EKEntryView: EKStyleView {
|
||||
|
||||
struct Content {
|
||||
var viewController: UIViewController!
|
||||
var view: UIView!
|
||||
var attributes: EKAttributes
|
||||
|
||||
init(viewController: UIViewController, attributes: EKAttributes) {
|
||||
self.viewController = viewController
|
||||
self.view = viewController.view
|
||||
self.attributes = attributes
|
||||
}
|
||||
|
||||
init(view: UIView, attributes: EKAttributes) {
|
||||
self.view = view
|
||||
self.attributes = attributes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Props
|
||||
|
||||
/** Background view */
|
||||
private var backgroundView: EKBackgroundView!
|
||||
|
||||
/** The content - contains the view, view controller, attributes */
|
||||
var content: Content
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
return UIView()
|
||||
}()
|
||||
|
||||
var attributes: EKAttributes {
|
||||
return content.attributes
|
||||
}
|
||||
|
||||
private lazy var contentContainerView: EKStyleView = {
|
||||
let contentContainerView = EKStyleView()
|
||||
self.addSubview(contentContainerView)
|
||||
contentContainerView.layoutToSuperview(axis: .vertically)
|
||||
contentContainerView.layoutToSuperview(axis: .horizontally)
|
||||
contentContainerView.clipsToBounds = true
|
||||
return contentContainerView
|
||||
}()
|
||||
|
||||
// MARK: Setup
|
||||
init(newEntry content: Content) {
|
||||
self.content = content
|
||||
super.init(frame: UIScreen.main.bounds)
|
||||
setupContentView()
|
||||
applyDropShadow()
|
||||
applyBackgroundToContentView()
|
||||
applyFrameStyle()
|
||||
adjustInnerContentAppearanceIfNeeded()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
applyFrameStyle()
|
||||
}
|
||||
|
||||
func transform(to view: UIView) {
|
||||
|
||||
let previousView = content.view
|
||||
content.view = view
|
||||
view.layoutIfNeeded()
|
||||
|
||||
let previousHeight = set(.height, of: frame.height, priority: .must)
|
||||
let nextHeight = set(.height, of: view.frame.height, priority: .defaultLow)
|
||||
|
||||
SwiftEntryKit.layoutIfNeeded()
|
||||
|
||||
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: [.beginFromCurrentState, .layoutSubviews], animations: {
|
||||
|
||||
previousHeight.priority = .defaultLow
|
||||
nextHeight.priority = .must
|
||||
|
||||
previousView!.alpha = 0
|
||||
|
||||
SwiftEntryKit.layoutIfNeeded()
|
||||
|
||||
}, completion: { (finished) in
|
||||
|
||||
view.alpha = 0
|
||||
|
||||
previousView!.removeFromSuperview()
|
||||
self.removeConstraints([previousHeight, nextHeight])
|
||||
|
||||
self.setupContentView()
|
||||
|
||||
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: [.curveEaseOut], animations: {
|
||||
view.alpha = 1
|
||||
}, completion: nil)
|
||||
})
|
||||
}
|
||||
|
||||
private func setupContentView() {
|
||||
contentView.addSubview(content.view)
|
||||
content.view.layoutToSuperview(axis: .horizontally)
|
||||
content.view.layoutToSuperview(axis: .vertically)
|
||||
|
||||
contentContainerView.addSubview(contentView)
|
||||
contentView.fillSuperview()
|
||||
contentView.layoutToSuperview(axis: .vertically)
|
||||
contentView.layoutToSuperview(axis: .horizontally)
|
||||
}
|
||||
|
||||
// Complementary logic for issue #117
|
||||
private func adjustInnerContentAppearanceIfNeeded() {
|
||||
guard let view = content.view as? EntryAppearanceDescriptor else {
|
||||
return
|
||||
}
|
||||
view.bottomCornerRadius = attributes.roundCorners.cornerValues?.radius ?? 0
|
||||
}
|
||||
|
||||
// Apply round corners
|
||||
private func applyFrameStyle() {
|
||||
backgroundView.applyFrameStyle(roundCorners: attributes.roundCorners, border: attributes.border)
|
||||
}
|
||||
|
||||
// Apply drop shadow
|
||||
private func applyDropShadow() {
|
||||
switch attributes.shadow {
|
||||
case .active(with: let value):
|
||||
applyDropShadow(withOffset: value.offset,
|
||||
opacity: value.opacity,
|
||||
radius: value.radius,
|
||||
color: value.color.color(for: traitCollection, mode: attributes.displayMode))
|
||||
case .none:
|
||||
removeDropShadow()
|
||||
}
|
||||
}
|
||||
|
||||
// Apply background
|
||||
private func applyBackgroundToContentView() {
|
||||
let attributes = content.attributes
|
||||
|
||||
let backgroundView = EKBackgroundView()
|
||||
backgroundView.style = .init(background: attributes.entryBackground,
|
||||
displayMode: attributes.displayMode)
|
||||
|
||||
switch attributes.positionConstraints.safeArea {
|
||||
case .empty(fillSafeArea: let fillSafeArea) where fillSafeArea: // Safe area filled with color
|
||||
insertSubview(backgroundView, at: 0)
|
||||
backgroundView.layoutToSuperview(axis: .horizontally)
|
||||
|
||||
var topInset: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
switch attributes.position {
|
||||
case .top:
|
||||
topInset = -EKWindowProvider.safeAreaInsets.top
|
||||
case .bottom, .center:
|
||||
bottomInset = EKWindowProvider.safeAreaInsets.bottom
|
||||
}
|
||||
|
||||
backgroundView.layoutToSuperview(.top, offset: topInset)
|
||||
backgroundView.layoutToSuperview(.bottom, offset: bottomInset)
|
||||
default: // Float case or a Toast with unfilled safe area
|
||||
contentView.insertSubview(backgroundView, at: 0)
|
||||
backgroundView.fillSuperview()
|
||||
}
|
||||
|
||||
self.backgroundView = backgroundView
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
applyDropShadow()
|
||||
}
|
||||
}
|
||||
268
Pods/SwiftEntryKit/Source/Infra/EKRootViewController.swift
generated
Normal file
268
Pods/SwiftEntryKit/Source/Infra/EKRootViewController.swift
generated
Normal file
@@ -0,0 +1,268 @@
|
||||
//
|
||||
// EntryViewController.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/19/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol EntryPresenterDelegate: AnyObject {
|
||||
var isResponsiveToTouches: Bool { set get }
|
||||
func displayPendingEntryOrRollbackWindow(dismissCompletionHandler: SwiftEntryKit.DismissCompletionHandler?)
|
||||
}
|
||||
|
||||
class EKRootViewController: UIViewController {
|
||||
|
||||
// MARK: - Props
|
||||
|
||||
private unowned let delegate: EntryPresenterDelegate
|
||||
|
||||
private var lastAttributes: EKAttributes!
|
||||
|
||||
private let backgroundView = EKBackgroundView()
|
||||
|
||||
private lazy var wrapperView: EKWrapperView = {
|
||||
return EKWrapperView()
|
||||
}()
|
||||
|
||||
/*
|
||||
Count the total amount of currently displaying entries,
|
||||
meaning, total subviews less one - the backgorund of the entry
|
||||
*/
|
||||
fileprivate var displayingEntryCount: Int {
|
||||
return view.subviews.count - 1
|
||||
}
|
||||
|
||||
fileprivate var isDisplaying: Bool {
|
||||
return lastEntry != nil
|
||||
}
|
||||
|
||||
private var lastEntry: EKContentView? {
|
||||
return view.subviews.last as? EKContentView
|
||||
}
|
||||
|
||||
private var isResponsive = false {
|
||||
didSet {
|
||||
wrapperView.isAbleToReceiveTouches = isResponsive
|
||||
delegate.isResponsiveToTouches = isResponsive
|
||||
}
|
||||
}
|
||||
|
||||
override var shouldAutorotate: Bool {
|
||||
if lastAttributes == nil {
|
||||
return true
|
||||
}
|
||||
return lastAttributes.positionConstraints.rotation.isEnabled
|
||||
}
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
guard let lastAttributes = lastAttributes else {
|
||||
return super.supportedInterfaceOrientations
|
||||
}
|
||||
switch lastAttributes.positionConstraints.rotation.supportedInterfaceOrientations {
|
||||
case .standard:
|
||||
return super.supportedInterfaceOrientations
|
||||
case .all:
|
||||
return .all
|
||||
}
|
||||
}
|
||||
|
||||
// Previous status bar style
|
||||
private let previousStatusBar: EKAttributes.StatusBar
|
||||
|
||||
private var statusBar: EKAttributes.StatusBar? = nil {
|
||||
didSet {
|
||||
if let statusBar = statusBar, ![statusBar, oldValue].contains(.ignored) {
|
||||
UIApplication.shared.set(statusBarStyle: statusBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
if [previousStatusBar, statusBar].contains(.ignored) {
|
||||
return super.preferredStatusBarStyle
|
||||
}
|
||||
return statusBar?.appearance.style ?? previousStatusBar.appearance.style
|
||||
}
|
||||
|
||||
override var prefersStatusBarHidden: Bool {
|
||||
if [previousStatusBar, statusBar].contains(.ignored) {
|
||||
return super.prefersStatusBarHidden
|
||||
}
|
||||
return !(statusBar?.appearance.visible ?? previousStatusBar.appearance.visible)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public init(with delegate: EntryPresenterDelegate) {
|
||||
self.delegate = delegate
|
||||
previousStatusBar = .currentStatusBar
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override public func loadView() {
|
||||
view = wrapperView
|
||||
view.insertSubview(backgroundView, at: 0)
|
||||
backgroundView.isUserInteractionEnabled = false
|
||||
backgroundView.fillSuperview()
|
||||
}
|
||||
|
||||
override public func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
statusBar = previousStatusBar
|
||||
}
|
||||
|
||||
// Set status bar
|
||||
func setStatusBarStyle(for attributes: EKAttributes) {
|
||||
statusBar = attributes.statusBar
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
func configure(entryView: EKEntryView) {
|
||||
|
||||
// In case the entry is a view controller, add the entry as child of root
|
||||
if let viewController = entryView.content.viewController {
|
||||
addChild(viewController)
|
||||
}
|
||||
|
||||
// Extract the attributes struct
|
||||
let attributes = entryView.attributes
|
||||
|
||||
// Assign attributes
|
||||
let previousAttributes = lastAttributes
|
||||
|
||||
// Remove the last entry
|
||||
removeLastEntry(lastAttributes: previousAttributes, keepWindow: true)
|
||||
|
||||
lastAttributes = attributes
|
||||
|
||||
let entryContentView = EKContentView(withEntryDelegate: self)
|
||||
view.addSubview(entryContentView)
|
||||
entryContentView.setup(with: entryView)
|
||||
|
||||
switch attributes.screenInteraction.defaultAction {
|
||||
case .forward:
|
||||
isResponsive = false
|
||||
default:
|
||||
isResponsive = true
|
||||
}
|
||||
|
||||
if previousAttributes?.statusBar != attributes.statusBar {
|
||||
setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
if shouldAutorotate {
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
// Check priority precedence for a given entry
|
||||
func canDisplay(attributes: EKAttributes) -> Bool {
|
||||
guard let lastAttributes = lastAttributes else {
|
||||
return true
|
||||
}
|
||||
return attributes.precedence.priority >= lastAttributes.precedence.priority
|
||||
}
|
||||
|
||||
// Removes last entry - can keep the window 'ON' if necessary
|
||||
private func removeLastEntry(lastAttributes: EKAttributes?, keepWindow: Bool) {
|
||||
guard let attributes = lastAttributes else {
|
||||
return
|
||||
}
|
||||
if attributes.popBehavior.isOverriden {
|
||||
lastEntry?.removePromptly()
|
||||
} else {
|
||||
popLastEntry()
|
||||
}
|
||||
}
|
||||
|
||||
// Make last entry exit using exitAnimation - animatedly
|
||||
func animateOutLastEntry(completionHandler: SwiftEntryKit.DismissCompletionHandler? = nil) {
|
||||
lastEntry?.dismissHandler = completionHandler
|
||||
lastEntry?.animateOut(pushOut: false)
|
||||
}
|
||||
|
||||
// Pops last entry (using pop animation) - animatedly
|
||||
func popLastEntry() {
|
||||
lastEntry?.animateOut(pushOut: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIResponder
|
||||
|
||||
extension EKRootViewController {
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
switch lastAttributes.screenInteraction.defaultAction {
|
||||
case .dismissEntry:
|
||||
lastEntry?.animateOut(pushOut: false)
|
||||
fallthrough
|
||||
default:
|
||||
lastAttributes.screenInteraction.customTapActions.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EntryScrollViewDelegate
|
||||
|
||||
extension EKRootViewController: EntryContentViewDelegate {
|
||||
|
||||
func didFinishDisplaying(entry: EKEntryView, keepWindowActive: Bool, dismissCompletionHandler: SwiftEntryKit.DismissCompletionHandler?) {
|
||||
guard !isDisplaying else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !keepWindowActive else {
|
||||
return
|
||||
}
|
||||
|
||||
delegate.displayPendingEntryOrRollbackWindow(dismissCompletionHandler: dismissCompletionHandler)
|
||||
}
|
||||
|
||||
func changeToInactive(withAttributes attributes: EKAttributes, pushOut: Bool) {
|
||||
guard displayingEntryCount <= 1 else {
|
||||
return
|
||||
}
|
||||
|
||||
let clear = {
|
||||
let style = EKBackgroundView.Style(background: .clear, displayMode: attributes.displayMode)
|
||||
self.changeBackground(to: style, duration: attributes.exitAnimation.totalDuration)
|
||||
}
|
||||
|
||||
guard pushOut else {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
guard let lastBackroundStyle = lastAttributes?.screenBackground else {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if lastBackroundStyle != attributes.screenBackground {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
func changeToActive(withAttributes attributes: EKAttributes) {
|
||||
let style = EKBackgroundView.Style(background: attributes.screenBackground,
|
||||
displayMode: attributes.displayMode)
|
||||
changeBackground(to: style, duration: attributes.entranceAnimation.totalDuration)
|
||||
}
|
||||
|
||||
private func changeBackground(to style: EKBackgroundView.Style, duration: TimeInterval) {
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: duration, delay: 0, options: [], animations: {
|
||||
self.backgroundView.style = style
|
||||
}, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
57
Pods/SwiftEntryKit/Source/Infra/EKStyleView.swift
generated
Normal file
57
Pods/SwiftEntryKit/Source/Infra/EKStyleView.swift
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// EKStyleView.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/28/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EKStyleView: UIView {
|
||||
|
||||
private lazy var borderLayer: CAShapeLayer = {
|
||||
return CAShapeLayer()
|
||||
}()
|
||||
|
||||
private var roundCorners: EKAttributes.RoundCorners!
|
||||
private var border: EKAttributes.Border!
|
||||
|
||||
var appliedStyle = false
|
||||
|
||||
func applyFrameStyle(roundCorners: EKAttributes.RoundCorners, border: EKAttributes.Border) {
|
||||
self.roundCorners = roundCorners
|
||||
self.border = border
|
||||
|
||||
var cornerRadius: CGFloat = 0
|
||||
var corners: UIRectCorner = []
|
||||
(corners, cornerRadius) = roundCorners.cornerValues ?? ([], 0)
|
||||
|
||||
let size = CGSize(width: cornerRadius, height: cornerRadius)
|
||||
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: size)
|
||||
|
||||
if !corners.isEmpty && cornerRadius > 0 {
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.path = path.cgPath
|
||||
layer.mask = maskLayer
|
||||
}
|
||||
|
||||
if let borderValues = border.borderValues {
|
||||
borderLayer.path = path.cgPath
|
||||
borderLayer.fillColor = UIColor.clear.cgColor
|
||||
borderLayer.strokeColor = borderValues.color.cgColor
|
||||
borderLayer.lineWidth = borderValues.width
|
||||
borderLayer.frame = bounds
|
||||
layer.addSublayer(borderLayer)
|
||||
}
|
||||
|
||||
appliedStyle = true
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let roundCorners = roundCorners, let border = border else {
|
||||
return
|
||||
}
|
||||
applyFrameStyle(roundCorners: roundCorners, border: border)
|
||||
}
|
||||
}
|
||||
50
Pods/SwiftEntryKit/Source/Infra/EKWindow.swift
generated
Normal file
50
Pods/SwiftEntryKit/Source/Infra/EKWindow.swift
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// EKWindow.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/19/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EKWindow: UIWindow {
|
||||
|
||||
var isAbleToReceiveTouches = false
|
||||
|
||||
init(with rootVC: UIViewController) {
|
||||
if #available(iOS 13.0, *) {
|
||||
// TODO: Patched to support SwiftUI out of the box but should require attendance
|
||||
if let scene = UIApplication.shared.connectedScenes.filter({$0.activationState == .foregroundActive}).first as? UIWindowScene {
|
||||
super.init(windowScene: scene)
|
||||
} else {
|
||||
super.init(frame: UIScreen.main.bounds)
|
||||
}
|
||||
} else {
|
||||
super.init(frame: UIScreen.main.bounds)
|
||||
}
|
||||
backgroundColor = .clear
|
||||
rootViewController = rootVC
|
||||
accessibilityViewIsModal = true
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
if isAbleToReceiveTouches {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
guard let rootVC = EKWindowProvider.shared.rootVC else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let view = rootVC.view.hitTest(point, with: event) {
|
||||
return view
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
231
Pods/SwiftEntryKit/Source/Infra/EKWindowProvider.swift
generated
Normal file
231
Pods/SwiftEntryKit/Source/Infra/EKWindowProvider.swift
generated
Normal file
@@ -0,0 +1,231 @@
|
||||
//
|
||||
// EKWindowProvider.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/19/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class EKWindowProvider: EntryPresenterDelegate {
|
||||
|
||||
/** The artificial safe area insets */
|
||||
static var safeAreaInsets: UIEdgeInsets {
|
||||
if #available(iOS 11.0, *) {
|
||||
return EKWindowProvider.shared.entryWindow?.rootViewController?.view?.safeAreaInsets ?? UIApplication.shared.keyWindow?.rootViewController?.view.safeAreaInsets ?? .zero
|
||||
} else {
|
||||
let statusBarMaxY = UIApplication.shared.statusBarFrame.maxY
|
||||
return UIEdgeInsets(top: statusBarMaxY, left: 0, bottom: 10, right: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Single access point */
|
||||
static let shared = EKWindowProvider()
|
||||
|
||||
/** Current entry window */
|
||||
var entryWindow: EKWindow!
|
||||
|
||||
/** Returns the root view controller if it is instantiated */
|
||||
var rootVC: EKRootViewController? {
|
||||
return entryWindow?.rootViewController as? EKRootViewController
|
||||
}
|
||||
|
||||
/** A window to go back to when the last entry has been dismissed */
|
||||
private var rollbackWindow: SwiftEntryKit.RollbackWindow!
|
||||
|
||||
/** The main rollback window to be used internally in case `rollbackWindow`'s value is `.main` */
|
||||
private weak var mainRollbackWindow: UIWindow?
|
||||
|
||||
/** Entry queueing heuristic */
|
||||
private let entryQueue = EKAttributes.Precedence.QueueingHeuristic.value.heuristic
|
||||
|
||||
private weak var entryView: EKEntryView!
|
||||
|
||||
/** Cannot be instantiated, customized, inherited */
|
||||
private init() {}
|
||||
|
||||
var isResponsiveToTouches: Bool {
|
||||
set {
|
||||
entryWindow.isAbleToReceiveTouches = newValue
|
||||
}
|
||||
get {
|
||||
return entryWindow.isAbleToReceiveTouches
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup and Teardown methods
|
||||
|
||||
// Prepare the window and the host view controller
|
||||
private func prepare(for attributes: EKAttributes, presentInsideKeyWindow: Bool) -> EKRootViewController? {
|
||||
let entryVC = setupWindowAndRootVC()
|
||||
guard entryVC.canDisplay(attributes: attributes) || attributes.precedence.isEnqueue else {
|
||||
return nil
|
||||
}
|
||||
entryVC.setStatusBarStyle(for: attributes)
|
||||
|
||||
entryWindow.windowLevel = attributes.windowLevel.value
|
||||
if presentInsideKeyWindow {
|
||||
entryWindow.makeKeyAndVisible()
|
||||
} else {
|
||||
entryWindow.isHidden = false
|
||||
}
|
||||
|
||||
return entryVC
|
||||
}
|
||||
|
||||
/** Boilerplate generic setup for entry-window and root-view-controller */
|
||||
private func setupWindowAndRootVC() -> EKRootViewController {
|
||||
let entryVC: EKRootViewController
|
||||
if entryWindow == nil {
|
||||
entryVC = EKRootViewController(with: self)
|
||||
entryWindow = EKWindow(with: entryVC)
|
||||
mainRollbackWindow = UIApplication.shared.keyWindow
|
||||
} else {
|
||||
entryVC = rootVC!
|
||||
}
|
||||
return entryVC
|
||||
}
|
||||
|
||||
/**
|
||||
Privately used to display an entry
|
||||
*/
|
||||
private func display(entryView: EKEntryView, using attributes: EKAttributes, presentInsideKeyWindow: Bool, rollbackWindow: SwiftEntryKit.RollbackWindow) {
|
||||
switch entryView.attributes.precedence {
|
||||
case .override(priority: _, dropEnqueuedEntries: let dropEnqueuedEntries):
|
||||
if dropEnqueuedEntries {
|
||||
entryQueue.removeAll()
|
||||
}
|
||||
show(entryView: entryView, presentInsideKeyWindow: presentInsideKeyWindow, rollbackWindow: rollbackWindow)
|
||||
case .enqueue where isCurrentlyDisplaying():
|
||||
entryQueue.enqueue(entry: .init(view: entryView, presentInsideKeyWindow: presentInsideKeyWindow, rollbackWindow: rollbackWindow))
|
||||
case .enqueue:
|
||||
show(entryView: entryView, presentInsideKeyWindow: presentInsideKeyWindow, rollbackWindow: rollbackWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exposed Actions
|
||||
|
||||
func queueContains(entryNamed name: String? = nil) -> Bool {
|
||||
if name == nil && !entryQueue.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let name = name {
|
||||
return entryQueue.contains(entryNamed: name)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns *true* if the currently displayed entry has the given name.
|
||||
In case *name* has the value of *nil*, the result is *true* if any entry is currently displayed.
|
||||
*/
|
||||
func isCurrentlyDisplaying(entryNamed name: String? = nil) -> Bool {
|
||||
guard let entryView = entryView else {
|
||||
return false
|
||||
}
|
||||
if let name = name { // Test for names equality
|
||||
return entryView.content.attributes.name == name
|
||||
} else { // Return true by default if the name is *nil*
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Transform current entry to view */
|
||||
func transform(to view: UIView) {
|
||||
entryView?.transform(to: view)
|
||||
}
|
||||
|
||||
/** Display a view using attributes */
|
||||
func display(view: UIView, using attributes: EKAttributes, presentInsideKeyWindow: Bool, rollbackWindow: SwiftEntryKit.RollbackWindow) {
|
||||
let entryView = EKEntryView(newEntry: .init(view: view, attributes: attributes))
|
||||
display(entryView: entryView, using: attributes, presentInsideKeyWindow: presentInsideKeyWindow, rollbackWindow: rollbackWindow)
|
||||
}
|
||||
|
||||
/** Display a view controller using attributes */
|
||||
func display(viewController: UIViewController, using attributes: EKAttributes, presentInsideKeyWindow: Bool, rollbackWindow: SwiftEntryKit.RollbackWindow) {
|
||||
let entryView = EKEntryView(newEntry: .init(viewController: viewController, attributes: attributes))
|
||||
display(entryView: entryView, using: attributes, presentInsideKeyWindow: presentInsideKeyWindow, rollbackWindow: rollbackWindow)
|
||||
}
|
||||
|
||||
/** Clear all entries immediately and display to the rollback window */
|
||||
func displayRollbackWindow() {
|
||||
if #available(iOS 13.0, *) {
|
||||
entryWindow.windowScene = nil
|
||||
}
|
||||
entryWindow = nil
|
||||
entryView = nil
|
||||
switch rollbackWindow! {
|
||||
case .main:
|
||||
if let mainRollbackWindow = mainRollbackWindow {
|
||||
mainRollbackWindow.makeKeyAndVisible()
|
||||
} else {
|
||||
UIApplication.shared.keyWindow?.makeKeyAndVisible()
|
||||
}
|
||||
case .custom(window: let window):
|
||||
window.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
/** Display a pending entry if there is any inside the queue */
|
||||
func displayPendingEntryOrRollbackWindow(dismissCompletionHandler: SwiftEntryKit.DismissCompletionHandler?) {
|
||||
if let next = entryQueue.dequeue() {
|
||||
|
||||
// Execute dismiss handler if needed before dequeuing (potentially) another entry
|
||||
dismissCompletionHandler?()
|
||||
|
||||
// Show the next entry in queue
|
||||
show(entryView: next.view, presentInsideKeyWindow: next.presentInsideKeyWindow, rollbackWindow: next.rollbackWindow)
|
||||
} else {
|
||||
|
||||
// Display the rollback window
|
||||
displayRollbackWindow()
|
||||
|
||||
// As a last step, invoke the dismissal method
|
||||
dismissCompletionHandler?()
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss entries according to a given descriptor */
|
||||
func dismiss(_ descriptor: SwiftEntryKit.EntryDismissalDescriptor, with completion: SwiftEntryKit.DismissCompletionHandler? = nil) {
|
||||
guard let rootVC = rootVC else {
|
||||
return
|
||||
}
|
||||
|
||||
switch descriptor {
|
||||
case .displayed:
|
||||
rootVC.animateOutLastEntry(completionHandler: completion)
|
||||
case .specific(entryName: let name):
|
||||
entryQueue.removeEntries(by: name)
|
||||
if entryView?.attributes.name == name {
|
||||
rootVC.animateOutLastEntry(completionHandler: completion)
|
||||
}
|
||||
case .prioritizedLowerOrEqualTo(priority: let priorityThreshold):
|
||||
entryQueue.removeEntries(withPriorityLowerOrEqualTo: priorityThreshold)
|
||||
if let currentPriority = entryView?.attributes.precedence.priority, currentPriority <= priorityThreshold {
|
||||
rootVC.animateOutLastEntry(completionHandler: completion)
|
||||
}
|
||||
case .enqueued:
|
||||
entryQueue.removeAll()
|
||||
case .all:
|
||||
entryQueue.removeAll()
|
||||
rootVC.animateOutLastEntry(completionHandler: completion)
|
||||
}
|
||||
}
|
||||
|
||||
/** Layout the view-hierarchy rooted in the window */
|
||||
func layoutIfNeeded() {
|
||||
entryWindow?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
/** Privately used to prepare the root view controller and show the entry immediately */
|
||||
private func show(entryView: EKEntryView, presentInsideKeyWindow: Bool, rollbackWindow: SwiftEntryKit.RollbackWindow) {
|
||||
guard let entryVC = prepare(for: entryView.attributes, presentInsideKeyWindow: presentInsideKeyWindow) else {
|
||||
return
|
||||
}
|
||||
entryVC.configure(entryView: entryView)
|
||||
self.entryView = entryView
|
||||
self.rollbackWindow = rollbackWindow
|
||||
}
|
||||
}
|
||||
23
Pods/SwiftEntryKit/Source/Infra/EKWrapperView.swift
generated
Normal file
23
Pods/SwiftEntryKit/Source/Infra/EKWrapperView.swift
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// EKWrapperView.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 4/19/18.
|
||||
// Copyright (c) 2018 huri000@gmail.com. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EKWrapperView: UIView {
|
||||
var isAbleToReceiveTouches = false
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
if isAbleToReceiveTouches {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
if let view = super.hitTest(point, with: event), view != self {
|
||||
return view
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
98
Pods/SwiftEntryKit/Source/Infra/EntryCachingHeuristic.swift
generated
Normal file
98
Pods/SwiftEntryKit/Source/Infra/EntryCachingHeuristic.swift
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// EKEntryCacher.swift
|
||||
// SwiftEntryKit
|
||||
//
|
||||
// Created by Daniel Huri on 9/1/18.
|
||||
// Copyright © 2018 CocoaPods. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct CachedEntry {
|
||||
let view: EKEntryView
|
||||
let presentInsideKeyWindow: Bool
|
||||
let rollbackWindow: SwiftEntryKit.RollbackWindow
|
||||
}
|
||||
|
||||
protocol EntryCachingHeuristic: AnyObject {
|
||||
var entries: [CachedEntry] { set get }
|
||||
var isEmpty: Bool { get }
|
||||
|
||||
func dequeue() -> CachedEntry?
|
||||
func enqueue(entry: CachedEntry)
|
||||
|
||||
func removeEntries(by name: String)
|
||||
func removeEntries(withPriorityLowerOrEqualTo priority: EKAttributes.Precedence.Priority)
|
||||
func remove(entry: CachedEntry)
|
||||
func removeAll()
|
||||
|
||||
func contains(entryNamed name: String) -> Bool
|
||||
}
|
||||
|
||||
extension EntryCachingHeuristic {
|
||||
|
||||
var isEmpty: Bool {
|
||||
return entries.isEmpty
|
||||
}
|
||||
|
||||
func contains(entryNamed name: String) -> Bool {
|
||||
return entries.contains { $0.view.attributes.name == name }
|
||||
}
|
||||
|
||||
func dequeue() -> CachedEntry? {
|
||||
guard let first = entries.first else {
|
||||
return nil
|
||||
}
|
||||
entries.removeFirst()
|
||||
return first
|
||||
}
|
||||
|
||||
func removeEntries(withPriorityLowerOrEqualTo priority: EKAttributes.Precedence.Priority) {
|
||||
while let index = (entries.firstIndex { $0.view.attributes.precedence.priority <= priority }) {
|
||||
entries.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func removeEntries(by name: String) {
|
||||
while let index = (entries.firstIndex { $0.view.attributes.name == name }) {
|
||||
entries.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(entry: CachedEntry) {
|
||||
guard let index = (entries.firstIndex { $0.view == entry.view }) else {
|
||||
return
|
||||
}
|
||||
entries.remove(at: index)
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
entries.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
class EKEntryChronologicalQueue: EntryCachingHeuristic {
|
||||
|
||||
var entries: [CachedEntry] = []
|
||||
|
||||
func enqueue(entry: CachedEntry) {
|
||||
entries.append(entry)
|
||||
}
|
||||
}
|
||||
|
||||
class EKEntryPriorityQueue: EntryCachingHeuristic {
|
||||
|
||||
var entries: [CachedEntry] = []
|
||||
|
||||
func enqueue(entry: CachedEntry) {
|
||||
let entryPriority = entry.view.attributes.precedence.priority
|
||||
let index = entries.firstIndex {
|
||||
return entryPriority > $0.view.attributes.precedence.priority
|
||||
}
|
||||
if let index = index {
|
||||
entries.insert(entry, at: index)
|
||||
} else {
|
||||
entries.append(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user