mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
466 lines
16 KiB
Swift
466 lines
16 KiB
Swift
//
|
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objc
|
|
public class MenuAction: NSObject {
|
|
let block: (MenuAction) -> Void
|
|
let image: UIImage
|
|
let title: String
|
|
let subtitle: String?
|
|
|
|
public init(image: UIImage, title: String, subtitle: String?, block: @escaping (MenuAction) -> Void) {
|
|
self.image = image
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.block = block
|
|
}
|
|
}
|
|
|
|
@objc
|
|
protocol MenuActionsViewControllerDelegate: class {
|
|
func menuActionsWillPresent(_ menuActionsViewController: MenuActionsViewController)
|
|
func menuActionsIsPresenting(_ menuActionsViewController: MenuActionsViewController)
|
|
func menuActionsDidPresent(_ menuActionsViewController: MenuActionsViewController)
|
|
|
|
func menuActionsIsDismissing(_ menuActionsViewController: MenuActionsViewController)
|
|
func menuActionsDidDismiss(_ menuActionsViewController: MenuActionsViewController)
|
|
}
|
|
|
|
@objc
|
|
class MenuActionsViewController: UIViewController, MenuActionSheetDelegate {
|
|
|
|
@objc
|
|
weak var delegate: MenuActionsViewControllerDelegate?
|
|
|
|
@objc
|
|
public let focusedInteraction: TSInteraction
|
|
|
|
private let focusedView: UIView
|
|
private let actionSheetView: MenuActionSheetView
|
|
|
|
deinit {
|
|
Logger.verbose("")
|
|
}
|
|
|
|
@objc
|
|
required init(focusedInteraction: TSInteraction, focusedView: UIView, actions: [MenuAction]) {
|
|
self.focusedView = focusedView
|
|
self.focusedInteraction = focusedInteraction
|
|
|
|
self.actionSheetView = MenuActionSheetView(actions: actions)
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
actionSheetView.delegate = self
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
// MARK: View LifeCycle
|
|
|
|
var actionSheetViewVerticalConstraint: NSLayoutConstraint?
|
|
|
|
override func loadView() {
|
|
self.view = UIView()
|
|
|
|
view.addSubview(actionSheetView)
|
|
|
|
actionSheetView.autoPinWidthToSuperview()
|
|
actionSheetView.setContentHuggingVerticalHigh()
|
|
actionSheetView.setCompressionResistanceHigh()
|
|
self.actionSheetViewVerticalConstraint = actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
|
|
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBackground))
|
|
self.view.addGestureRecognizer(tapGesture)
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(true)
|
|
|
|
self.animatePresentation()
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
Logger.debug("")
|
|
super.viewDidDisappear(animated)
|
|
|
|
// When the user has manually dismissed the menu, we do a nice animation
|
|
// but if the view otherwise disappears (e.g. due to resigning active),
|
|
// we still want to give the delegate the information it needs to restore it's UI.
|
|
delegate?.menuActionsDidDismiss(self)
|
|
}
|
|
|
|
// MARK: Orientation
|
|
|
|
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
return DefaultUIInterfaceOrientationMask()
|
|
}
|
|
|
|
// MARK: Present / Dismiss animations
|
|
|
|
var snapshotView: UIView?
|
|
|
|
private func addSnapshotFocusedView() -> UIView? {
|
|
guard let snapshotView = self.focusedView.snapshotView(afterScreenUpdates: false) else {
|
|
owsFailDebug("snapshotView was unexpectedly nil")
|
|
return nil
|
|
}
|
|
view.addSubview(snapshotView)
|
|
|
|
guard let focusedViewSuperview = focusedView.superview else {
|
|
owsFailDebug("focusedViewSuperview was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
|
|
snapshotView.frame = convertedFrame
|
|
|
|
return snapshotView
|
|
}
|
|
|
|
private func animatePresentation() {
|
|
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
|
|
owsFailDebug("actionSheetViewVerticalConstraint was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
guard let focusedViewSuperview = focusedView.superview else {
|
|
owsFailDebug("focusedViewSuperview was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
// darken background
|
|
guard let snapshotView = addSnapshotFocusedView() else {
|
|
owsFailDebug("snapshotView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
self.snapshotView = snapshotView
|
|
snapshotView.superview?.layoutIfNeeded()
|
|
|
|
let backgroundDuration: TimeInterval = 0.1
|
|
UIView.animate(withDuration: backgroundDuration) {
|
|
let alpha: CGFloat = isDarkMode ? 0.7 : 0.4
|
|
self.view.backgroundColor = UIColor.black.withAlphaComponent(alpha)
|
|
}
|
|
|
|
self.actionSheetView.superview?.layoutIfNeeded()
|
|
|
|
let oldFocusFrame = self.view.convert(focusedView.frame, from: focusedViewSuperview)
|
|
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
|
|
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
self.delegate?.menuActionsWillPresent(self)
|
|
UIView.animate(withDuration: 0.2,
|
|
delay: backgroundDuration,
|
|
options: .curveEaseOut,
|
|
animations: {
|
|
self.actionSheetView.superview?.layoutIfNeeded()
|
|
let newSheetFrame = self.actionSheetView.frame
|
|
|
|
var newFocusFrame = oldFocusFrame
|
|
|
|
// Position focused item just over the action sheet.
|
|
let overlap: CGFloat = (oldFocusFrame.maxY + self.vSpacing) - newSheetFrame.minY
|
|
newFocusFrame.origin.y = oldFocusFrame.origin.y - overlap
|
|
|
|
snapshotView.frame = newFocusFrame
|
|
|
|
self.delegate?.menuActionsIsPresenting(self)
|
|
},
|
|
completion: { (_) in
|
|
self.delegate?.menuActionsDidPresent(self)
|
|
})
|
|
}
|
|
|
|
@objc
|
|
public let vSpacing: CGFloat = 10
|
|
|
|
@objc
|
|
public var focusUI: UIView {
|
|
return actionSheetView
|
|
}
|
|
|
|
private func animateDismiss(action: MenuAction?) {
|
|
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
|
|
owsFailDebug("actionSheetVerticalConstraint was unexpectedly nil")
|
|
delegate?.menuActionsDidDismiss(self)
|
|
return
|
|
}
|
|
|
|
guard let snapshotView = self.snapshotView else {
|
|
owsFailDebug("snapshotView was unexpectedly nil")
|
|
delegate?.menuActionsDidDismiss(self)
|
|
return
|
|
}
|
|
|
|
self.actionSheetView.superview?.layoutIfNeeded()
|
|
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
|
|
|
|
let dismissDuration: TimeInterval = 0.2
|
|
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
|
|
UIView.animate(withDuration: dismissDuration,
|
|
delay: 0,
|
|
options: .curveEaseOut,
|
|
animations: {
|
|
self.view.backgroundColor = UIColor.clear
|
|
self.actionSheetView.superview?.layoutIfNeeded()
|
|
// this helps when focused view is above navbars, etc.
|
|
snapshotView.alpha = 0
|
|
|
|
self.delegate?.menuActionsIsDismissing(self)
|
|
},
|
|
completion: { _ in
|
|
self.view.isHidden = true
|
|
self.delegate?.menuActionsDidDismiss(self)
|
|
if let action = action {
|
|
action.block(action)
|
|
}
|
|
})
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
@objc
|
|
func didTapBackground() {
|
|
animateDismiss(action: nil)
|
|
}
|
|
|
|
// MARK: MenuActionSheetDelegate
|
|
|
|
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction) {
|
|
animateDismiss(action: action)
|
|
}
|
|
}
|
|
|
|
protocol MenuActionSheetDelegate: class {
|
|
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction)
|
|
}
|
|
|
|
class MenuActionSheetView: UIView, MenuActionViewDelegate {
|
|
|
|
private let actionStackView: UIStackView
|
|
private var actions: [MenuAction]
|
|
private var actionViews: [MenuActionView]
|
|
private var hapticFeedback: SelectionHapticFeedback
|
|
private var hasEverHighlightedAction = false
|
|
|
|
weak var delegate: MenuActionSheetDelegate?
|
|
|
|
override var bounds: CGRect {
|
|
didSet {
|
|
updateMask()
|
|
}
|
|
}
|
|
|
|
convenience init(actions: [MenuAction]) {
|
|
self.init(frame: CGRect.zero)
|
|
actions.forEach { self.addAction($0) }
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
actionStackView = UIStackView()
|
|
actionStackView.axis = .vertical
|
|
actionStackView.spacing = CGHairlineWidth()
|
|
|
|
actions = []
|
|
actionViews = []
|
|
hapticFeedback = SelectionHapticFeedback()
|
|
|
|
super.init(frame: frame)
|
|
|
|
backgroundColor = (isDarkMode
|
|
? UIColor.ows_gray90
|
|
: UIColor.ows_gray05)
|
|
addSubview(actionStackView)
|
|
actionStackView.autoPinEdgesToSuperviewEdges()
|
|
|
|
self.clipsToBounds = true
|
|
|
|
let touchGesture = UILongPressGestureRecognizer(target: self, action: #selector(didTouch(gesture:)))
|
|
touchGesture.minimumPressDuration = 0.0
|
|
touchGesture.allowableMovement = CGFloat.greatestFiniteMagnitude
|
|
self.addGestureRecognizer(touchGesture)
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
@objc
|
|
public func didTouch(gesture: UIGestureRecognizer) {
|
|
switch gesture.state {
|
|
case .possible:
|
|
break
|
|
case .began:
|
|
let location = gesture.location(in: self)
|
|
highlightActionView(location: location, fromView: self)
|
|
case .changed:
|
|
let location = gesture.location(in: self)
|
|
highlightActionView(location: location, fromView: self)
|
|
case .ended:
|
|
Logger.debug("ended")
|
|
let location = gesture.location(in: self)
|
|
selectActionView(location: location, fromView: self)
|
|
case .cancelled:
|
|
Logger.debug("canceled")
|
|
unhighlightAllActionViews()
|
|
case .failed:
|
|
Logger.debug("failed")
|
|
unhighlightAllActionViews()
|
|
}
|
|
}
|
|
|
|
public func addAction(_ action: MenuAction) {
|
|
actions.append(action)
|
|
|
|
let actionView = MenuActionView(action: action)
|
|
actionView.delegate = self
|
|
actionViews.append(actionView)
|
|
|
|
self.actionStackView.addArrangedSubview(actionView)
|
|
}
|
|
|
|
// MARK: MenuActionViewDelegate
|
|
|
|
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction) {
|
|
self.delegate?.actionSheet(self, didSelectAction: action)
|
|
}
|
|
|
|
// MARK:
|
|
|
|
private func updateMask() {
|
|
let cornerRadius: CGFloat = 16
|
|
let path: UIBezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
|
|
let mask = CAShapeLayer()
|
|
mask.path = path.cgPath
|
|
self.layer.mask = mask
|
|
}
|
|
|
|
private func unhighlightAllActionViews() {
|
|
for actionView in actionViews {
|
|
actionView.isHighlighted = false
|
|
}
|
|
}
|
|
|
|
private func actionView(touchedBy touchPoint: CGPoint, fromView: UIView) -> MenuActionView? {
|
|
for actionView in actionViews {
|
|
let convertedPoint = actionView.convert(touchPoint, from: fromView)
|
|
if actionView.point(inside: convertedPoint, with: nil) {
|
|
return actionView
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func highlightActionView(location: CGPoint, fromView: UIView) {
|
|
guard let touchedView = actionView(touchedBy: location, fromView: fromView) else {
|
|
unhighlightAllActionViews()
|
|
return
|
|
}
|
|
|
|
if hasEverHighlightedAction, !touchedView.isHighlighted {
|
|
self.hapticFeedback.selectionChanged()
|
|
}
|
|
touchedView.isHighlighted = true
|
|
hasEverHighlightedAction = true
|
|
|
|
self.actionViews.filter { $0 != touchedView }.forEach { $0.isHighlighted = false }
|
|
}
|
|
|
|
private func selectActionView(location: CGPoint, fromView: UIView) {
|
|
guard let selectedView: MenuActionView = actionView(touchedBy: location, fromView: fromView) else {
|
|
unhighlightAllActionViews()
|
|
return
|
|
}
|
|
selectedView.isHighlighted = true
|
|
self.actionViews.filter { $0 != selectedView }.forEach { $0.isHighlighted = false }
|
|
delegate?.actionSheet(self, didSelectAction: selectedView.action)
|
|
}
|
|
}
|
|
|
|
protocol MenuActionViewDelegate: class {
|
|
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction)
|
|
}
|
|
|
|
class MenuActionView: UIButton {
|
|
public weak var delegate: MenuActionViewDelegate?
|
|
public let action: MenuAction
|
|
|
|
required init(action: MenuAction) {
|
|
self.action = action
|
|
|
|
super.init(frame: CGRect.zero)
|
|
|
|
isUserInteractionEnabled = true
|
|
backgroundColor = defaultBackgroundColor
|
|
|
|
let textColor = isLightMode ? UIColor.black : UIColor.white
|
|
|
|
var image = action.image
|
|
image = image.withRenderingMode(.alwaysTemplate)
|
|
let imageView = UIImageView(image: image)
|
|
imageView.tintColor = textColor.withAlphaComponent(Values.unimportantElementOpacity)
|
|
let imageWidth: CGFloat = 24
|
|
imageView.autoSetDimensions(to: CGSize(width: imageWidth, height: imageWidth))
|
|
imageView.isUserInteractionEnabled = false
|
|
|
|
let titleLabel = UILabel()
|
|
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
|
titleLabel.textColor = textColor
|
|
titleLabel.text = action.title
|
|
titleLabel.isUserInteractionEnabled = false
|
|
|
|
let subtitleLabel = UILabel()
|
|
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
|
subtitleLabel.textColor = textColor.withAlphaComponent(Values.unimportantElementOpacity)
|
|
subtitleLabel.text = action.subtitle
|
|
subtitleLabel.isUserInteractionEnabled = false
|
|
|
|
let textColumn = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
|
|
textColumn.axis = .vertical
|
|
textColumn.alignment = .leading
|
|
textColumn.isUserInteractionEnabled = false
|
|
|
|
let contentRow = UIStackView(arrangedSubviews: [imageView, textColumn])
|
|
contentRow.axis = .horizontal
|
|
contentRow.alignment = .center
|
|
contentRow.spacing = 12
|
|
contentRow.isLayoutMarginsRelativeArrangement = true
|
|
contentRow.layoutMargins = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16)
|
|
contentRow.isUserInteractionEnabled = false
|
|
|
|
self.addSubview(contentRow)
|
|
contentRow.autoPinEdgesToSuperviewMargins()
|
|
contentRow.autoSetDimension(.height, toSize: 56, relation: .greaterThanOrEqual)
|
|
|
|
self.isUserInteractionEnabled = false
|
|
}
|
|
|
|
private var defaultBackgroundColor: UIColor {
|
|
return isLightMode ? UIColor(hex: 0xFCFCFC) : UIColor(hex: 0x1B1B1B)
|
|
}
|
|
|
|
private var highlightedBackgroundColor: UIColor {
|
|
return isLightMode ? UIColor(hex: 0xDFDFDF) : UIColor(hex: 0x0C0C0C)
|
|
}
|
|
|
|
override var isHighlighted: Bool {
|
|
didSet {
|
|
self.backgroundColor = isHighlighted ? highlightedBackgroundColor : defaultBackgroundColor
|
|
}
|
|
}
|
|
|
|
@objc
|
|
func didPress(sender: Any) {
|
|
Logger.debug("")
|
|
self.delegate?.actionView(self, didSelectAction: action)
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
}
|