Merge branch 'eb/contextMenuAnimations'

This commit is contained in:
Eugene Bistolas 2021-07-29 11:43:45 -10:00
commit dc391eb398
8 changed files with 304 additions and 29 deletions

View File

@ -10,6 +10,11 @@ public class ContextMenuActionsAccessory: ContextMenuTargetedPreviewAccessory, C
private let menuView: ContextMenuActionsView
private let minimumScale: CGFloat = 0.2
private let minimumOpacity: CGFloat = 0.2
private let springDamping: CGFloat = 0.8
private let springInitialVelocity: CGFloat = 1
public init(
menu: ContextMenu,
accessoryAlignment: AccessoryAlignment
@ -19,19 +24,104 @@ public class ContextMenuActionsAccessory: ContextMenuTargetedPreviewAccessory, C
menuView = ContextMenuActionsView(menu: menu)
super.init(accessoryView: menuView, accessoryAlignment: accessoryAlignment)
menuView.delegate = self
animateAccessoryPresentationAlongsidePreview = true
}
override func touchLocationInViewDidChange(locationInView: CGPoint) {
override func animateIn(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
setMenuLayerAnchorPoint()
menuView.transform = CGAffineTransform.scale(minimumScale)
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: springDamping,
initialSpringVelocity: springInitialVelocity,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: {
self.menuView.transform = CGAffineTransform.identity
},
completion: nil
)
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = minimumOpacity
opacityAnimation.toValue = 1
opacityAnimation.duration = duration
opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
menuView.layer.add(opacityAnimation, forKey: "insertOpacity")
}
override func animateOut(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
setMenuLayerAnchorPoint()
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: springDamping,
initialSpringVelocity: springInitialVelocity,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: {
self.menuView.transform = CGAffineTransform.scale(self.minimumScale)
},
completion: { _ in
completion()
}
)
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 1
opacityAnimation.toValue = 0
opacityAnimation.duration = duration - 0.1
opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
opacityAnimation.isRemovedOnCompletion = false
opacityAnimation.fillMode = .forwards
menuView.layer.add(opacityAnimation, forKey: "removeOpacity")
}
private func setMenuLayerAnchorPoint() {
let alignment = delegate?.contextMenuTargetedPreviewAccessoryPreviewAlignment(self)
let xAnchor: CGFloat
switch alignment {
case .center:
xAnchor = 0.5
case .left:
xAnchor = 0
case .right:
xAnchor = 1
case .none:
xAnchor = 0
}
let frame = menuView.frame
menuView.layer.anchorPoint = CGPoint(x: xAnchor, y: 0)
menuView.frame = frame
}
override func touchLocationInViewDidChange(
locationInView: CGPoint
) {
menuView.handleGestureChanged(locationInView: locationInView)
}
override func touchLocationInViewDidEnd(locationInView: CGPoint) {
override func touchLocationInViewDidEnd(
locationInView: CGPoint
) {
menuView.handleGestureEnded(locationInView: locationInView)
}
func contextMenuActionViewDidSelectAction(contextMenuAction: ContextMenuAction) {
delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self)
contextMenuAction.handler(contextMenuAction)
delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self, completion: {
contextMenuAction.handler(contextMenuAction)
})
}
}

View File

@ -52,7 +52,8 @@ public class ContextMenu {
}
protocol ContextMenuTargetedPreviewAccessoryInteractionDelegate: AnyObject {
func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory)
func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping () -> Void)
func contextMenuTargetedPreviewAccessoryPreviewAlignment(_ accessory: ContextMenuTargetedPreviewAccessory) -> ContextMenuTargetedPreview.Alignment
func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping (String) -> Void)
}
@ -85,6 +86,8 @@ public class ContextMenuTargetedPreviewAccessory {
var accessoryAlignment: AccessoryAlignment
var landscapeAccessoryAlignment: AccessoryAlignment?
var animateAccessoryPresentationAlongsidePreview: Bool = false
weak var delegate: ContextMenuTargetedPreviewAccessoryInteractionDelegate?
init(
@ -97,6 +100,7 @@ public class ContextMenuTargetedPreviewAccessory {
func animateIn(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
completion()
@ -104,6 +108,7 @@ public class ContextMenuTargetedPreviewAccessory {
func animateOut(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
completion()
@ -130,7 +135,6 @@ public class ContextMenuTargetedPreview {
case left
case center
case right
}
public let view: UIView?

View File

@ -8,11 +8,19 @@ protocol ContextMenuControllerDelegate: AnyObject {
func contextMenuControllerRequestsDismissal(_ contextMenuController: ContextMenuController)
}
protocol ContextMenuViewDelegate: AnyObject {
private protocol ContextMenuViewDelegate: AnyObject {
func contextMenuViewPreviewSourceFrame(_ contextMenuView: ContextMenuHostView) -> CGRect
func contextMenuViewAnimationState(_ contextMenuView: ContextMenuHostView) -> ContextMenuAnimationState
func contextMenuViewPreviewFrameForAccessoryLayout(_ contextMenuView: ContextMenuHostView) -> CGRect
}
class ContextMenuHostView: UIView {
private enum ContextMenuAnimationState {
case none
case animateIn
case animateOut
}
private class ContextMenuHostView: UIView {
weak var delegate: ContextMenuViewDelegate?
var previewViewAlignment: ContextMenuTargetedPreview.Alignment = .center
@ -73,8 +81,14 @@ class ContextMenuHostView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
blurView?.frame = bounds
let animationState = delegate?.contextMenuViewAnimationState(self) ?? .none
if let previewView = self.previewView {
previewView.frame = targetPreviewFrame()
// Let the controller manage the preview's frame if animating
if animationState != .animateOut {
previewView.frame = targetPreviewFrame()
}
}
if let accessories = accessoryViews {
@ -199,8 +213,10 @@ class ContextMenuHostView: UIView {
}
private func layoutAccessoryView(_ accessory: ContextMenuTargetedPreviewAccessory) {
guard let previewFrame = previewView?.frame else {
owsFailDebug("Cannot layout accessory views without a preview view")
let previewFrame = delegate?.contextMenuViewPreviewFrameForAccessoryLayout(self) ?? CGRect.zero
let animationState = delegate?.contextMenuViewAnimationState(self) ?? .none
guard !(accessory.animateAccessoryPresentationAlongsidePreview && animationState == .animateOut) else {
return
}
@ -215,6 +231,14 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
let contextMenuConfiguration: ContextMenuConfiguration
let menuAccessory: ContextMenuActionsAccessory?
var previewView: UIView? {
if let hostView = view as? ContextMenuHostView {
return hostView.previewView
}
return nil
}
var gestureRecognizer: UIGestureRecognizer?
var localPanGestureRecoginzer: UIPanGestureRecognizer?
@ -222,6 +246,16 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
private let deadZoneRadius: CGFloat = 30
private var initialTouchLocation: CGPoint?
private var animationState: ContextMenuAnimationState = .none
private var animateOutPreviewFrame = CGRect.zero
private let animationDuration = 0.4
private var previewShadowVisible = false {
didSet {
self.previewView?.layer.shadowOpacity = previewShadowVisible ? 0.3 : 0
}
}
var accessoryViews: [ContextMenuTargetedPreviewAccessory] {
var accessories = contextMenuPreview.accessoryViews
if let menuAccessory = self.menuAccessory {
@ -231,8 +265,7 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
}
lazy var blurView: UIVisualEffectView = {
let effect = UIBlurEffect(style: UIBlurEffect.Style.regular)
return UIVisualEffectView(effect: effect)
return UIVisualEffectView(effect: nil)
}()
private var emojiPickerSheet: EmojiPickerSheet?
@ -268,6 +301,11 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
contextMenuView.previewView = contextMenuPreview.snapshot
contextMenuView.accessoryViews = accessoryViews
self.previewView?.isHidden = true
self.previewView?.layer.shadowRadius = 12
self.previewView?.layer.shadowOffset = CGSize(width: 0, height: 4)
self.previewView?.layer.shadowColor = UIColor.ows_black.cgColor
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized(sender:)))
view.addGestureRecognizer(tapGesture)
}
@ -280,13 +318,119 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard let previewView = previewView else {
owsFailDebug("Cannot animate without preview view!")
return
}
animationState = .animateIn
UIView.animate(withDuration: animationDuration / 2.0) {
self.blurView.effect = UIBlurEffect(style: UIBlurEffect.Style.regular)
self.previewShadowVisible = true
}
previewView.isHidden = false
let finalFrame = previewView.frame
let initialFrame = previewSourceFrame()
let shiftPreview = finalFrame != initialFrame
if shiftPreview {
previewView.frame = initialFrame
let yDelta = finalFrame.y - initialFrame.y
for accessory in accessoryViews {
if accessory.animateAccessoryPresentationAlongsidePreview {
accessory.accessoryView.frame.y -= yDelta
}
}
UIView.animate(
withDuration: animationDuration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 1,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: {
for accessory in self.accessoryViews {
if accessory.animateAccessoryPresentationAlongsidePreview {
accessory.accessoryView.frame.y += yDelta
}
}
self.previewView?.frame = finalFrame
}) { _ in
self.animationState = .none
}
}
// Animate in accessories
for accessory in accessoryViews {
accessory.animateIn(duration: 0.2) { }
accessory.animateIn(duration: animationDuration, previewWillShift: shiftPreview) { }
}
}
// MARK: Public
public func animateOut(_ completion: @escaping () -> Void) {
guard let previewView = previewView else {
owsFailDebug("Cannot animate without preview view!")
completion()
return
}
let dispatchGroup = DispatchGroup()
animationState = .animateOut
dispatchGroup.enter()
UIView.animate(withDuration: animationDuration / 2.0) {
self.blurView.effect = nil
self.previewShadowVisible = false
} completion: { _ in
dispatchGroup.leave()
}
let finalFrame = previewSourceFrame()
let initialFrame = previewView.frame
animateOutPreviewFrame = initialFrame
let shiftPreview = finalFrame != initialFrame
if shiftPreview {
let yDelta = finalFrame.y - initialFrame.y
dispatchGroup.enter()
UIView.animate(
withDuration: animationDuration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 1,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: {
for accessory in self.accessoryViews {
if accessory.animateAccessoryPresentationAlongsidePreview {
accessory.accessoryView.frame.y += yDelta
}
}
self.previewView?.frame = finalFrame
},
completion: { _ in
dispatchGroup.leave()
}
)
}
// Animate in accessories
for accessory in accessoryViews {
dispatchGroup.enter()
accessory.animateOut(duration: animationDuration, previewWillShift: shiftPreview) {
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
self.animationState = .none
completion()
}
}
// MARK: Gesture Recognizer Support
public func gestureDidChange() {
if let locationInView = gestureRecognizer?.location(in: view) {
@ -367,7 +511,25 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
// MARK: ContextMenuViewDelegate
func contextMenuViewPreviewSourceFrame(_ contextMenuView: ContextMenuHostView) -> CGRect {
fileprivate func contextMenuViewPreviewSourceFrame(_ contextMenuView: ContextMenuHostView) -> CGRect {
return previewSourceFrame()
}
fileprivate func contextMenuViewAnimationState(_ contextMenuView: ContextMenuHostView) -> ContextMenuAnimationState {
return animationState
}
fileprivate func contextMenuViewPreviewFrameForAccessoryLayout(_ contextMenuView: ContextMenuHostView) -> CGRect {
if animationState == .animateOut {
return animateOutPreviewFrame
}
return previewView?.frame ?? CGRect.zero
}
// MARK: Private
private func previewSourceFrame() -> CGRect {
guard let sourceView = contextMenuPreview.view else {
owsFailDebug("Expected source view")
return CGRect.zero
@ -375,8 +537,6 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur
return view.convert(sourceView.frame, from: sourceView)
}
// MARK: Private
@objc
private func tapGestureRecognized(sender: UIGestureRecognizer) {
delegate?.contextMenuControllerRequestsDismissal(self)

View File

@ -109,13 +109,16 @@ public class ContextMenuInteraction: NSObject, UIInteraction {
return accessory
}
public func dismissMenu() {
public func dismissMenu(completion: @escaping() -> Void ) {
if let configuarion = self.configuration {
delegate?.contextMenuInteraction(self, willEndForConfiguration: configuarion)
}
contextMenuController?.view.removeFromSuperview()
contextMenuController = nil
contextMenuController?.animateOut({
completion()
self.contextMenuController?.view.removeFromSuperview()
self.contextMenuController = nil
})
}
// MARK: Private
@ -129,12 +132,16 @@ public class ContextMenuInteraction: NSObject, UIInteraction {
extension ContextMenuInteraction: ContextMenuControllerDelegate, ContextMenuTargetedPreviewAccessoryInteractionDelegate {
func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory) {
dismissMenu()
func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping () -> Void) {
dismissMenu(completion: completion)
}
func contextMenuTargetedPreviewAccessoryPreviewAlignment(_ accessory: ContextMenuTargetedPreviewAccessory) -> ContextMenuTargetedPreview.Alignment {
return contextMenuController?.contextMenuPreview.alignment ?? .center
}
func contextMenuControllerRequestsDismissal(_ contextMenuController: ContextMenuController) {
dismissMenu()
dismissMenu(completion: { })
}
func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(

View File

@ -24,18 +24,31 @@ public class ContextMenuRectionBarAccessory: ContextMenuTargetedPreviewAccessory
let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: [(.top, .exterior), (isIncomingMessage ? .leading : .trailing, .interior)], alignmentOffset: CGPoint(x: alignmnetOffset, y: -12))
super.init(accessoryView: reactionPicker, accessoryAlignment: alignment)
reactionPicker.delegate = self
reactionPicker.isHidden = true
}
override func animateIn(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
reactionPicker.playPresentationAnimation(duration: duration)
completion()
let animateIn = {
self.reactionPicker.isHidden = false
self.reactionPicker.playPresentationAnimation(duration: 0.2)
completion()
}
if previewWillShift {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { animateIn() }
} else {
animateIn()
}
}
override func animateOut(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void
) {
reactionPicker.playDismissalAnimation(duration: duration, completion: completion)
@ -69,7 +82,7 @@ public class ContextMenuRectionBarAccessory: ContextMenuTargetedPreviewAccessory
reactionPicker.playDismissalAnimation(duration: 0.2) {
self.didSelectReactionHandler?(message, reaction, isRemoving)
self.delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self)
self.delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self, completion: { })
}
}
@ -85,7 +98,7 @@ public class ContextMenuRectionBarAccessory: ContextMenuTargetedPreviewAccessory
self.delegate?.contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(self) { emojiString in
let isRemoving = emojiString == self.itemViewModel?.reactionState?.localUserEmoji
self.didSelectReactionHandler?(message, emojiString, isRemoving)
self.delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self)
self.delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self, completion: { })
}
}
}

View File

@ -1658,6 +1658,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
avatarView.frame = componentView.avatarView.bounds
let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: [(.leading, .exterior), (.bottom, .interior)], alignmentOffset: CGPoint(x: -8, y: 0))
let avatarViewAccessory = ContextMenuTargetedPreviewAccessory(accessoryView: avatarView, accessoryAlignment: alignment)
avatarViewAccessory.animateAccessoryPresentationAlongsidePreview = true
return [avatarViewAccessory]
} else {
return nil

View File

@ -199,7 +199,7 @@ extension ConversationViewController: ContextMenuInteractionDelegate {
let actionWithType = actions.first { $0.actionType == type }
if let messageAction = actionWithType {
let contextMenuAction = ContextMenuAction(title: messageAction.contextMenuTitle, image: messageAction.image, attributes: messageAction.contextMenuAttributes, handler: { _ in
messageAction.block(self)
messageAction.block(nil)
})
contextMenuActions.append(contextMenuAction)

View File

@ -358,7 +358,7 @@ public class DebugFlags: BaseFlags {
public static let permissiveGroupUpdateInfoMessages = build.includes(.dev)
@objc
public static let showContextMenuDebugRects = build.includes(.dev)
public static let showContextMenuDebugRects = false
@objc
public static let verboseNotificationLogging = build.includes(.qa)