mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
906 lines
38 KiB
Swift
906 lines
38 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import ComponentFlow
|
|
import ComponentDisplayAdapters
|
|
import AppBundle
|
|
import MultilineTextComponent
|
|
|
|
private let titleFontWithIcon = Font.medium(13.0)
|
|
private let titleFontWithoutIcon = Font.regular(17.0)
|
|
|
|
private final class SwipeOptionsGestureRecognizer: UIPanGestureRecognizer {
|
|
public var validatedGesture = false
|
|
public var firstLocation: CGPoint = CGPoint()
|
|
|
|
public var allowAnyDirection = false
|
|
public var lastVelocity: CGPoint = CGPoint()
|
|
|
|
override public init(target: Any?, action: Selector?) {
|
|
super.init(target: target, action: action)
|
|
|
|
if #available(iOS 13.4, *) {
|
|
self.allowedScrollTypesMask = .continuous
|
|
}
|
|
|
|
self.maximumNumberOfTouches = 1
|
|
}
|
|
|
|
override public func reset() {
|
|
super.reset()
|
|
|
|
self.validatedGesture = false
|
|
}
|
|
|
|
public func becomeCancelled() {
|
|
self.state = .cancelled
|
|
}
|
|
|
|
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
let touch = touches.first!
|
|
self.firstLocation = touch.location(in: self.view)
|
|
}
|
|
|
|
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
let location = touches.first!.location(in: self.view)
|
|
let translation = CGPoint(x: location.x - self.firstLocation.x, y: location.y - self.firstLocation.y)
|
|
|
|
if !self.validatedGesture {
|
|
if !self.allowAnyDirection && translation.x > 0.0 {
|
|
self.state = .failed
|
|
} else if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 {
|
|
self.state = .failed
|
|
} else if abs(translation.x) > 4.0 && abs(translation.y) * 2.5 < abs(translation.x) {
|
|
self.validatedGesture = true
|
|
}
|
|
}
|
|
|
|
if self.validatedGesture {
|
|
self.lastVelocity = self.velocity(in: self.view)
|
|
super.touchesMoved(touches, with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
open class ListItemSwipeOptionContainer: UIView, UIGestureRecognizerDelegate {
|
|
public struct Option: Equatable {
|
|
public enum Icon: Equatable {
|
|
case none
|
|
case image(image: UIImage)
|
|
|
|
public static func ==(lhs: Icon, rhs: Icon) -> Bool {
|
|
switch lhs {
|
|
case .none:
|
|
if case .none = rhs {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
case let .image(lhsImage):
|
|
if case let .image(rhsImage) = rhs, lhsImage == rhsImage {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public let key: AnyHashable
|
|
public let title: String
|
|
public let icon: Icon
|
|
public let color: UIColor
|
|
public let textColor: UIColor
|
|
|
|
public init(key: AnyHashable, title: String, icon: Icon, color: UIColor, textColor: UIColor) {
|
|
self.key = key
|
|
self.title = title
|
|
self.icon = icon
|
|
self.color = color
|
|
self.textColor = textColor
|
|
}
|
|
|
|
public static func ==(lhs: Option, rhs: Option) -> Bool {
|
|
if lhs.key != rhs.key {
|
|
return false
|
|
}
|
|
if lhs.title != rhs.title {
|
|
return false
|
|
}
|
|
if !lhs.color.isEqual(rhs.color) {
|
|
return false
|
|
}
|
|
if !lhs.textColor.isEqual(rhs.textColor) {
|
|
return false
|
|
}
|
|
if lhs.icon != rhs.icon {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private enum OptionAlignment {
|
|
case left
|
|
case right
|
|
}
|
|
|
|
private final class OptionView: UIView {
|
|
private let backgroundView: UIView
|
|
private let title = ComponentView<Empty>()
|
|
private var iconView: UIImageView?
|
|
|
|
private let titleString: String
|
|
private let textColor: UIColor
|
|
|
|
private var titleSize: CGSize?
|
|
|
|
var alignment: OptionAlignment?
|
|
var isExpanded: Bool = false
|
|
|
|
init(title: String, icon: Option.Icon, color: UIColor, textColor: UIColor) {
|
|
self.titleString = title
|
|
self.textColor = textColor
|
|
|
|
self.backgroundView = UIView()
|
|
|
|
switch icon {
|
|
case let .image(image):
|
|
let iconView = UIImageView()
|
|
iconView.image = image.withRenderingMode(.alwaysTemplate)
|
|
iconView.tintColor = textColor
|
|
self.iconView = iconView
|
|
case .none:
|
|
self.iconView = nil
|
|
}
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
self.addSubview(self.backgroundView)
|
|
if let iconView = self.iconView {
|
|
self.addSubview(iconView)
|
|
}
|
|
self.backgroundView.backgroundColor = color
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func updateLayout(
|
|
isFirst: Bool,
|
|
isLeft: Bool,
|
|
baseSize: CGSize,
|
|
alignment: OptionAlignment,
|
|
isExpanded: Bool,
|
|
extendedWidth: CGFloat,
|
|
sideInset: CGFloat,
|
|
transition: ComponentTransition,
|
|
additive: Bool,
|
|
revealFactor: CGFloat,
|
|
animateIconMovement: Bool
|
|
) {
|
|
var animateAdditive = false
|
|
if additive && !transition.animation.isImmediate && self.isExpanded != isExpanded {
|
|
animateAdditive = true
|
|
}
|
|
|
|
let backgroundFrame: CGRect
|
|
if isFirst {
|
|
backgroundFrame = CGRect(origin: CGPoint(x: isLeft ? -400.0 : 0.0, y: 0.0), size: CGSize(width: extendedWidth + 400.0, height: baseSize.height))
|
|
} else {
|
|
backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: extendedWidth, height: baseSize.height))
|
|
}
|
|
let deltaX: CGFloat
|
|
if animateAdditive {
|
|
let previousFrame = self.backgroundView.frame
|
|
self.backgroundView.frame = backgroundFrame
|
|
if isLeft {
|
|
deltaX = previousFrame.width - backgroundFrame.width
|
|
} else {
|
|
deltaX = -(previousFrame.width - backgroundFrame.width)
|
|
}
|
|
if !animateIconMovement {
|
|
transition.animatePosition(view: self.backgroundView, from: CGPoint(x: deltaX, y: 0.0), to: CGPoint(), additive: true)
|
|
}
|
|
} else {
|
|
deltaX = 0.0
|
|
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
|
|
}
|
|
|
|
self.alignment = alignment
|
|
self.isExpanded = isExpanded
|
|
let titleSize = self.titleSize ?? CGSize(width: 32.0, height: 10.0)
|
|
var contentRect = CGRect(origin: CGPoint(), size: baseSize)
|
|
switch alignment {
|
|
case .left:
|
|
contentRect.origin.x = 0.0
|
|
case .right:
|
|
contentRect.origin.x = extendedWidth - contentRect.width
|
|
}
|
|
|
|
if let iconView = self.iconView, let imageSize = iconView.image?.size {
|
|
let iconOffset: CGFloat = -9.0
|
|
let titleIconSpacing: CGFloat = 11.0
|
|
let iconFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - imageSize.width + sideInset) / 2.0), y: contentRect.midY - imageSize.height / 2.0 + iconOffset), size: imageSize)
|
|
if animateAdditive {
|
|
let iconOffsetX = animateIconMovement ? iconView.frame.minX - iconFrame.minX : deltaX
|
|
iconView.frame = iconFrame
|
|
transition.animatePosition(view: iconView, from: CGPoint(x: iconOffsetX, y: 0.0), to: CGPoint(), additive: true)
|
|
} else {
|
|
transition.setFrame(view: iconView, frame: iconFrame)
|
|
}
|
|
|
|
let titleFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.midY + titleIconSpacing), size: titleSize)
|
|
if let titleView = self.title.view {
|
|
if titleView.superview == nil {
|
|
self.addSubview(titleView)
|
|
}
|
|
if animateAdditive {
|
|
let titleOffsetX = animateIconMovement ? titleView.frame.minX - titleFrame.minX : deltaX
|
|
titleView.frame = titleFrame
|
|
transition.animatePosition(view: titleView, from: CGPoint(x: titleOffsetX, y: 0.0), to: CGPoint(), additive: true)
|
|
} else {
|
|
transition.setFrame(view: titleView, frame: titleFrame)
|
|
}
|
|
}
|
|
} else {
|
|
let titleFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.minY + floor((baseSize.height - titleSize.height) / 2.0)), size: titleSize)
|
|
|
|
if let titleView = self.title.view {
|
|
if titleView.superview == nil {
|
|
self.addSubview(titleView)
|
|
}
|
|
if animateAdditive {
|
|
let titleOffsetX = animateIconMovement ? titleView.frame.minX - titleFrame.minX : deltaX
|
|
titleView.frame = titleFrame
|
|
transition.animatePosition(view: titleView, from: CGPoint(x: titleOffsetX, y: 0.0), to: CGPoint(), additive: true)
|
|
} else {
|
|
transition.setFrame(view: titleView, frame: titleFrame)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func calculateSize(_ constrainedSize: CGSize) -> CGSize {
|
|
let titleSize = self.title.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: self.titleString, font: self.iconView == nil ? titleFontWithoutIcon : titleFontWithIcon, textColor: self.textColor))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 200.0, height: 100.0)
|
|
)
|
|
self.titleSize = titleSize
|
|
|
|
var maxWidth = titleSize.width
|
|
if let iconView = self.iconView, let image = iconView.image {
|
|
maxWidth = max(image.size.width, maxWidth)
|
|
}
|
|
return CGSize(width: max(74.0, maxWidth + 20.0), height: constrainedSize.height)
|
|
}
|
|
}
|
|
|
|
public final class OptionsView: UIView {
|
|
private let optionSelected: (Option) -> Void
|
|
private let tapticAction: () -> Void
|
|
|
|
private var options: [Option] = []
|
|
private var isLeft: Bool = false
|
|
|
|
private var optionViews: [OptionView] = []
|
|
private var revealOffset: CGFloat = 0.0
|
|
private var sideInset: CGFloat = 0.0
|
|
|
|
public init(optionSelected: @escaping (Option) -> Void, tapticAction: @escaping () -> Void) {
|
|
self.optionSelected = optionSelected
|
|
self.tapticAction = tapticAction
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
let gestureRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
|
|
gestureRecognizer.tapActionAtPoint = { _ in
|
|
return .waitForSingleTap
|
|
}
|
|
self.addGestureRecognizer(gestureRecognizer)
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public func setOptions(_ options: [Option], isLeft: Bool) {
|
|
if self.options != options || self.isLeft != isLeft {
|
|
self.options = options
|
|
self.isLeft = isLeft
|
|
for optionView in self.optionViews {
|
|
optionView.removeFromSuperview()
|
|
}
|
|
self.optionViews = options.map { option in
|
|
return OptionView(title: option.title, icon: option.icon, color: option.color, textColor: option.textColor)
|
|
}
|
|
if isLeft {
|
|
for optionView in self.optionViews.reversed() {
|
|
self.addSubview(optionView)
|
|
}
|
|
} else {
|
|
for optionView in self.optionViews {
|
|
self.addSubview(optionView)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func calculateSize(_ constrainedSize: CGSize) -> CGSize {
|
|
var maxWidth: CGFloat = 0.0
|
|
for optionView in self.optionViews {
|
|
let nodeSize = optionView.calculateSize(constrainedSize)
|
|
maxWidth = max(nodeSize.width, maxWidth)
|
|
}
|
|
return CGSize(width: maxWidth * CGFloat(self.optionViews.count), height: constrainedSize.height)
|
|
}
|
|
|
|
public func updateRevealOffset(offset: CGFloat, sideInset: CGFloat, transition: ComponentTransition) {
|
|
self.revealOffset = offset
|
|
self.sideInset = sideInset
|
|
self.updateNodesLayout(transition: transition)
|
|
}
|
|
|
|
private func updateNodesLayout(transition: ComponentTransition) {
|
|
let size = self.bounds.size
|
|
if size.width.isLessThanOrEqualTo(0.0) || self.optionViews.isEmpty {
|
|
return
|
|
}
|
|
let basicNodeWidth = floor((size.width - abs(self.sideInset)) / CGFloat(self.optionViews.count))
|
|
let lastNodeWidth = size.width - basicNodeWidth * CGFloat(self.optionViews.count - 1)
|
|
let revealFactor = self.revealOffset / size.width
|
|
let boundaryRevealFactor: CGFloat
|
|
if self.optionViews.count > 2 {
|
|
boundaryRevealFactor = 1.0 + 16.0 / size.width
|
|
} else {
|
|
boundaryRevealFactor = 1.0 + basicNodeWidth / size.width
|
|
}
|
|
let startingOffset: CGFloat
|
|
if self.isLeft {
|
|
startingOffset = size.width + max(0.0, abs(revealFactor) - 1.0) * size.width
|
|
} else {
|
|
startingOffset = 0.0
|
|
}
|
|
|
|
var completionCount = self.optionViews.count
|
|
let intermediateCompletion = {
|
|
}
|
|
|
|
var i = self.isLeft ? (self.optionViews.count - 1) : 0
|
|
while i >= 0 && i < self.optionViews.count {
|
|
let optionView = self.optionViews[i]
|
|
let nodeWidth = i == (self.optionViews.count - 1) ? lastNodeWidth : basicNodeWidth
|
|
var nodeTransition = transition
|
|
var isExpanded = false
|
|
if (self.isLeft && i == 0) || (!self.isLeft && i == self.optionViews.count - 1) {
|
|
if abs(revealFactor) > boundaryRevealFactor {
|
|
isExpanded = true
|
|
}
|
|
}
|
|
if let _ = optionView.alignment, optionView.isExpanded != isExpanded {
|
|
nodeTransition = !transition.animation.isImmediate ? transition : .easeInOut(duration: 0.2)
|
|
if transition.animation.isImmediate {
|
|
self.tapticAction()
|
|
}
|
|
}
|
|
|
|
var sideInset: CGFloat = 0.0
|
|
if i == self.optionViews.count - 1 {
|
|
sideInset = self.sideInset
|
|
}
|
|
|
|
let extendedWidth: CGFloat
|
|
let nodeLeftOffset: CGFloat
|
|
if isExpanded {
|
|
nodeLeftOffset = 0.0
|
|
extendedWidth = size.width * max(1.0, abs(revealFactor))
|
|
} else if self.isLeft {
|
|
let offset = basicNodeWidth * CGFloat(self.optionViews.count - 1 - i)
|
|
extendedWidth = (size.width - offset) * max(1.0, abs(revealFactor))
|
|
nodeLeftOffset = startingOffset - extendedWidth - floorToScreenPixels(offset * abs(revealFactor))
|
|
} else {
|
|
let offset = basicNodeWidth * CGFloat(i)
|
|
extendedWidth = (size.width - offset) * max(1.0, abs(revealFactor))
|
|
nodeLeftOffset = startingOffset + floorToScreenPixels(offset * abs(revealFactor))
|
|
}
|
|
|
|
transition.setFrame(view: optionView, frame: CGRect(origin: CGPoint(x: nodeLeftOffset, y: 0.0), size: CGSize(width: extendedWidth, height: size.height)), completion: { _ in
|
|
completionCount -= 1
|
|
intermediateCompletion()
|
|
})
|
|
|
|
var nodeAlignment: OptionAlignment
|
|
if (self.optionViews.count > 1) {
|
|
nodeAlignment = self.isLeft ? .right : .left
|
|
} else {
|
|
if self.isLeft {
|
|
nodeAlignment = isExpanded ? .right : .left
|
|
} else {
|
|
nodeAlignment = isExpanded ? .left : .right
|
|
}
|
|
}
|
|
let animateIconMovement = self.optionViews.count == 1
|
|
optionView.updateLayout(isFirst: (self.isLeft && i == 0) || (!self.isLeft && i == self.optionViews.count - 1), isLeft: self.isLeft, baseSize: CGSize(width: nodeWidth, height: size.height), alignment: nodeAlignment, isExpanded: isExpanded, extendedWidth: extendedWidth, sideInset: sideInset, transition: nodeTransition, additive: transition.animation.isImmediate, revealFactor: revealFactor, animateIconMovement: animateIconMovement)
|
|
|
|
if self.isLeft {
|
|
i -= 1
|
|
} else {
|
|
i += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
|
|
if case .ended = recognizer.state, let gesture = recognizer.lastRecognizedGestureAndLocation?.0, case .tap = gesture {
|
|
let location = recognizer.location(in: self)
|
|
var selectedOption: Int?
|
|
|
|
var i = self.isLeft ? 0 : (self.optionViews.count - 1)
|
|
while i >= 0 && i < self.optionViews.count {
|
|
if self.optionViews[i].frame.contains(location) {
|
|
selectedOption = i
|
|
break
|
|
}
|
|
if self.isLeft {
|
|
i += 1
|
|
} else {
|
|
i -= 1
|
|
}
|
|
}
|
|
if let selectedOption {
|
|
self.optionSelected(self.options[selectedOption])
|
|
}
|
|
}
|
|
}
|
|
|
|
public func isDisplayingExtendedAction() -> Bool {
|
|
return self.optionViews.contains(where: { $0.isExpanded })
|
|
}
|
|
}
|
|
|
|
private var validLayout: (size: CGSize, leftInset: CGFloat, reftInset: CGFloat)?
|
|
|
|
private var leftRevealView: OptionsView?
|
|
private var rightRevealView: OptionsView?
|
|
private var revealOptions: (left: [Option], right: [Option]) = ([], [])
|
|
|
|
private var initialRevealOffset: CGFloat = 0.0
|
|
public private(set) var revealOffset: CGFloat = 0.0
|
|
|
|
private var recognizer: SwipeOptionsGestureRecognizer?
|
|
private var tapRecognizer: UITapGestureRecognizer?
|
|
private var hapticFeedback: HapticFeedback?
|
|
|
|
private var allowAnyDirection: Bool = false
|
|
|
|
public var updateRevealOffset: ((CGFloat, ComponentTransition) -> Void)?
|
|
public var revealOptionsInteractivelyOpened: (() -> Void)?
|
|
public var revealOptionsInteractivelyClosed: (() -> Void)?
|
|
public var revealOptionSelected: ((Option, Bool) -> Void)?
|
|
|
|
open var controlsContainer: UIView {
|
|
return self
|
|
}
|
|
|
|
public var isDisplayingRevealedOptions: Bool {
|
|
return !self.revealOffset.isZero
|
|
}
|
|
|
|
override public init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
let recognizer = SwipeOptionsGestureRecognizer(target: self, action: #selector(self.revealGesture(_:)))
|
|
self.recognizer = recognizer
|
|
recognizer.delegate = self
|
|
recognizer.allowAnyDirection = self.allowAnyDirection
|
|
self.addGestureRecognizer(recognizer)
|
|
|
|
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.revealTapGesture(_:)))
|
|
self.tapRecognizer = tapRecognizer
|
|
tapRecognizer.delegate = self
|
|
self.addGestureRecognizer(tapRecognizer)
|
|
|
|
self.disablesInteractiveTransitionGestureRecognizer = self.allowAnyDirection
|
|
|
|
self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
|
|
guard let self else {
|
|
return false
|
|
}
|
|
if !self.revealOffset.isZero {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
open func setRevealOptions(_ options: (left: [Option], right: [Option])) {
|
|
if self.revealOptions == options {
|
|
return
|
|
}
|
|
let previousOptions = self.revealOptions
|
|
let wasEmpty = self.revealOptions.left.isEmpty && self.revealOptions.right.isEmpty
|
|
self.revealOptions = options
|
|
let isEmpty = options.left.isEmpty && options.right.isEmpty
|
|
if options.left.isEmpty {
|
|
if let _ = self.leftRevealView {
|
|
self.recognizer?.becomeCancelled()
|
|
self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3))
|
|
}
|
|
} else if previousOptions.left != options.left {
|
|
}
|
|
if options.right.isEmpty {
|
|
if let _ = self.rightRevealView {
|
|
self.recognizer?.becomeCancelled()
|
|
self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3))
|
|
}
|
|
} else if previousOptions.right != options.right {
|
|
if let _ = self.rightRevealView {
|
|
}
|
|
}
|
|
if wasEmpty != isEmpty {
|
|
self.recognizer?.isEnabled = !isEmpty
|
|
}
|
|
let allowAnyDirection = !options.left.isEmpty || !self.revealOffset.isZero
|
|
if allowAnyDirection != self.allowAnyDirection {
|
|
self.allowAnyDirection = allowAnyDirection
|
|
self.recognizer?.allowAnyDirection = allowAnyDirection
|
|
self.disablesInteractiveTransitionGestureRecognizer = allowAnyDirection
|
|
}
|
|
}
|
|
|
|
override open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
if let recognizer = self.recognizer, gestureRecognizer == self.tapRecognizer {
|
|
return abs(self.revealOffset) > 0.0 && !recognizer.validatedGesture
|
|
} else if let recognizer = self.recognizer, gestureRecognizer == self.recognizer, recognizer.numberOfTouches == 0 {
|
|
let translation = recognizer.velocity(in: recognizer.view)
|
|
if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
if let recognizer = self.recognizer, otherGestureRecognizer == recognizer {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
/*if gestureRecognizer === self.recognizer && otherGestureRecognizer is InteractiveTransitionGestureRecognizer {
|
|
return true
|
|
}*/
|
|
return false
|
|
}
|
|
|
|
@objc private func revealTapGesture(_ recognizer: UITapGestureRecognizer) {
|
|
if case .ended = recognizer.state {
|
|
self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3))
|
|
self.revealOptionsInteractivelyClosed?()
|
|
}
|
|
}
|
|
|
|
@objc private func revealGesture(_ recognizer: SwipeOptionsGestureRecognizer) {
|
|
guard let (size, _, _) = self.validLayout else {
|
|
return
|
|
}
|
|
switch recognizer.state {
|
|
case .began:
|
|
if let leftRevealView = self.leftRevealView {
|
|
let revealSize = leftRevealView.bounds.size
|
|
let location = recognizer.location(in: self)
|
|
if location.x < revealSize.width {
|
|
recognizer.becomeCancelled()
|
|
} else {
|
|
self.initialRevealOffset = self.revealOffset
|
|
}
|
|
} else if let rightRevealView = self.rightRevealView {
|
|
let revealSize = rightRevealView.bounds.size
|
|
let location = recognizer.location(in: self)
|
|
if location.x > size.width - revealSize.width {
|
|
recognizer.becomeCancelled()
|
|
} else {
|
|
self.initialRevealOffset = self.revealOffset
|
|
}
|
|
} else {
|
|
if self.revealOptions.left.isEmpty && self.revealOptions.right.isEmpty {
|
|
recognizer.becomeCancelled()
|
|
}
|
|
self.initialRevealOffset = self.revealOffset
|
|
}
|
|
case .changed:
|
|
var translation = recognizer.translation(in: self)
|
|
translation.x += self.initialRevealOffset
|
|
if self.revealOptions.left.isEmpty {
|
|
translation.x = min(0.0, translation.x)
|
|
}
|
|
if self.leftRevealView == nil && CGFloat(0.0).isLess(than: translation.x) {
|
|
self.setupAndAddLeftRevealNode()
|
|
self.revealOptionsInteractivelyOpened?()
|
|
} else if self.rightRevealView == nil && translation.x.isLess(than: 0.0) {
|
|
self.setupAndAddRightRevealNode()
|
|
self.revealOptionsInteractivelyOpened?()
|
|
}
|
|
self.updateRevealOffsetInternal(offset: translation.x, transition: .immediate)
|
|
if self.leftRevealView == nil && self.rightRevealView == nil {
|
|
self.revealOptionsInteractivelyClosed?()
|
|
}
|
|
case .ended, .cancelled:
|
|
guard let recognizer = self.recognizer else {
|
|
break
|
|
}
|
|
|
|
if let leftRevealView = self.leftRevealView {
|
|
let velocity = recognizer.velocity(in: self)
|
|
let revealSize = leftRevealView.bounds.size
|
|
var reveal = false
|
|
if abs(velocity.x) < 100.0 {
|
|
if self.initialRevealOffset.isZero && self.revealOffset > 0.0 {
|
|
reveal = true
|
|
} else if self.revealOffset > revealSize.width {
|
|
reveal = true
|
|
} else {
|
|
reveal = false
|
|
}
|
|
} else {
|
|
if velocity.x > 0.0 {
|
|
reveal = true
|
|
} else {
|
|
reveal = false
|
|
}
|
|
}
|
|
|
|
var selectedOption: Option?
|
|
if reveal && leftRevealView.isDisplayingExtendedAction() {
|
|
reveal = false
|
|
selectedOption = self.revealOptions.left.first
|
|
} else {
|
|
self.updateRevealOffsetInternal(offset: reveal ? revealSize.width : 0.0, transition: .spring(duration: 0.3))
|
|
}
|
|
|
|
if let selectedOption = selectedOption {
|
|
self.revealOptionSelected?(selectedOption, true)
|
|
} else {
|
|
if !reveal {
|
|
self.revealOptionsInteractivelyClosed?()
|
|
}
|
|
}
|
|
} else if let rightRevealView = self.rightRevealView {
|
|
let velocity = recognizer.velocity(in: self)
|
|
let revealSize = rightRevealView.bounds.size
|
|
var reveal = false
|
|
if abs(velocity.x) < 100.0 {
|
|
if self.initialRevealOffset.isZero && self.revealOffset < 0.0 {
|
|
reveal = true
|
|
} else if self.revealOffset < -revealSize.width {
|
|
reveal = true
|
|
} else {
|
|
reveal = false
|
|
}
|
|
} else {
|
|
if velocity.x < 0.0 {
|
|
reveal = true
|
|
} else {
|
|
reveal = false
|
|
}
|
|
}
|
|
|
|
var selectedOption: Option?
|
|
if reveal && rightRevealView.isDisplayingExtendedAction() {
|
|
reveal = false
|
|
selectedOption = self.revealOptions.right.last
|
|
} else {
|
|
self.updateRevealOffsetInternal(offset: reveal ? -revealSize.width : 0.0, transition: .spring(duration: 0.3))
|
|
}
|
|
|
|
if let selectedOption = selectedOption {
|
|
self.revealOptionSelected?(selectedOption, true)
|
|
} else {
|
|
if !reveal {
|
|
self.revealOptionsInteractivelyClosed?()
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func setupAndAddLeftRevealNode() {
|
|
if !self.revealOptions.left.isEmpty {
|
|
let revealView = OptionsView(optionSelected: { [weak self] option in
|
|
self?.revealOptionSelected?(option, false)
|
|
}, tapticAction: { [weak self] in
|
|
self?.hapticImpact()
|
|
})
|
|
revealView.setOptions(self.revealOptions.left, isLeft: true)
|
|
self.leftRevealView = revealView
|
|
|
|
if let (size, leftInset, _) = self.validLayout {
|
|
var revealSize = revealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height))
|
|
revealSize.width += leftInset
|
|
|
|
revealView.frame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize)
|
|
revealView.updateRevealOffset(offset: 0.0, sideInset: leftInset, transition: .immediate)
|
|
}
|
|
|
|
self.controlsContainer.addSubview(revealView)
|
|
}
|
|
}
|
|
|
|
private func setupAndAddRightRevealNode() {
|
|
if !self.revealOptions.right.isEmpty {
|
|
let revealView = OptionsView(optionSelected: { [weak self] option in
|
|
self?.revealOptionSelected?(option, false)
|
|
}, tapticAction: { [weak self] in
|
|
self?.hapticImpact()
|
|
})
|
|
revealView.setOptions(self.revealOptions.right, isLeft: false)
|
|
self.rightRevealView = revealView
|
|
|
|
if let (size, _, rightInset) = self.validLayout {
|
|
var revealSize = revealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height))
|
|
revealSize.width += rightInset
|
|
|
|
revealView.frame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize)
|
|
revealView.updateRevealOffset(offset: 0.0, sideInset: -rightInset, transition: .immediate)
|
|
}
|
|
|
|
self.controlsContainer.addSubview(revealView)
|
|
}
|
|
}
|
|
|
|
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat) {
|
|
self.validLayout = (size, leftInset, rightInset)
|
|
|
|
if let leftRevealView = self.leftRevealView {
|
|
var revealSize = leftRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height))
|
|
revealSize.width += leftInset
|
|
leftRevealView.frame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize)
|
|
}
|
|
|
|
if let rightRevealView = self.rightRevealView {
|
|
var revealSize = rightRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height))
|
|
revealSize.width += rightInset
|
|
rightRevealView.frame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize)
|
|
}
|
|
}
|
|
|
|
open func updateRevealOffsetInternal(offset: CGFloat, transition: ComponentTransition, completion: (() -> Void)? = nil) {
|
|
self.revealOffset = offset
|
|
guard let (size, leftInset, rightInset) = self.validLayout else {
|
|
return
|
|
}
|
|
|
|
var leftRevealCompleted = true
|
|
var rightRevealCompleted = true
|
|
let intermediateCompletion = {
|
|
if leftRevealCompleted && rightRevealCompleted {
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
if let leftRevealView = self.leftRevealView {
|
|
leftRevealCompleted = false
|
|
|
|
let revealSize = leftRevealView.bounds.size
|
|
|
|
let revealFrame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize)
|
|
let revealNodeOffset = -self.revealOffset
|
|
leftRevealView.updateRevealOffset(offset: revealNodeOffset, sideInset: leftInset, transition: transition)
|
|
|
|
if CGFloat(offset).isLessThanOrEqualTo(0.0) {
|
|
self.leftRevealView = nil
|
|
transition.setFrame(view: leftRevealView, frame: revealFrame, completion: { [weak leftRevealView] _ in
|
|
leftRevealView?.removeFromSuperview()
|
|
|
|
leftRevealCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
} else {
|
|
transition.setFrame(view: leftRevealView, frame: revealFrame, completion: { _ in
|
|
leftRevealCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
}
|
|
}
|
|
if let rightRevealView = self.rightRevealView {
|
|
rightRevealCompleted = false
|
|
|
|
let revealSize = rightRevealView.bounds.size
|
|
|
|
let revealFrame = CGRect(origin: CGPoint(x: min(size.width, size.width + self.revealOffset), y: 0.0), size: revealSize)
|
|
let revealNodeOffset = -self.revealOffset
|
|
rightRevealView.updateRevealOffset(offset: revealNodeOffset, sideInset: -rightInset, transition: transition)
|
|
|
|
if CGFloat(0.0).isLessThanOrEqualTo(offset) {
|
|
self.rightRevealView = nil
|
|
transition.setFrame(view: rightRevealView, frame: revealFrame, completion: { [weak rightRevealView] _ in
|
|
rightRevealView?.removeFromSuperview()
|
|
|
|
rightRevealCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
} else {
|
|
transition.setFrame(view: rightRevealView, frame: revealFrame, completion: { _ in
|
|
rightRevealCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
}
|
|
}
|
|
let allowAnyDirection = !self.revealOptions.left.isEmpty || !offset.isZero
|
|
if allowAnyDirection != self.allowAnyDirection {
|
|
self.allowAnyDirection = allowAnyDirection
|
|
self.recognizer?.allowAnyDirection = allowAnyDirection
|
|
self.disablesInteractiveTransitionGestureRecognizer = allowAnyDirection
|
|
}
|
|
|
|
self.updateRevealOffset?(offset, transition)
|
|
}
|
|
|
|
open func setRevealOptionsOpened(_ value: Bool, animated: Bool) {
|
|
if value != !self.revealOffset.isZero {
|
|
if !self.revealOffset.isZero {
|
|
self.recognizer?.becomeCancelled()
|
|
}
|
|
let transition: ComponentTransition
|
|
if animated {
|
|
transition = .spring(duration: 0.3)
|
|
} else {
|
|
transition = .immediate
|
|
}
|
|
if value {
|
|
if self.rightRevealView == nil {
|
|
self.setupAndAddRightRevealNode()
|
|
if let rightRevealView = self.rightRevealView, let validLayout = self.validLayout {
|
|
let revealSize = rightRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: validLayout.size.height))
|
|
self.updateRevealOffsetInternal(offset: -revealSize.width, transition: transition)
|
|
}
|
|
}
|
|
} else if !self.revealOffset.isZero {
|
|
self.updateRevealOffsetInternal(offset: 0.0, transition: transition)
|
|
}
|
|
}
|
|
}
|
|
|
|
open func animateRevealOptionsFill(completion: (() -> Void)? = nil) {
|
|
if let validLayout = self.validLayout {
|
|
self.layer.allowsGroupOpacity = true
|
|
self.updateRevealOffsetInternal(offset: -validLayout.0.width - 74.0, transition: .spring(duration: 0.3), completion: {
|
|
self.layer.allowsGroupOpacity = false
|
|
completion?()
|
|
})
|
|
}
|
|
}
|
|
|
|
open var preventsTouchesToOtherItems: Bool {
|
|
return self.isDisplayingRevealedOptions
|
|
}
|
|
|
|
open func touchesToOtherItemsPrevented() {
|
|
if self.isDisplayingRevealedOptions {
|
|
self.setRevealOptionsOpened(false, animated: true)
|
|
}
|
|
}
|
|
|
|
private func hapticImpact() {
|
|
if self.hapticFeedback == nil {
|
|
self.hapticFeedback = HapticFeedback()
|
|
}
|
|
self.hapticFeedback?.impact(.medium)
|
|
}
|
|
}
|