diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift index 8b4764af63..5d1ff036c3 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift @@ -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) + }) } } diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift index b1770dd547..e7069c3320 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift @@ -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? diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift index c525a4ef5b..7bd61e6155 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift @@ -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) diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift index 7efddf71e4..f3353f47e2 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift @@ -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( diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuReactionBarAccessory.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuReactionBarAccessory.swift index 6ba8e63528..606f32cda9 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuReactionBarAccessory.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuReactionBarAccessory.swift @@ -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: { }) } } } diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentMessage.swift b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentMessage.swift index c7bc4528f6..d700374509 100644 --- a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentMessage.swift +++ b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentMessage.swift @@ -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 diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift index d330ec2f79..8c06faf31d 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift @@ -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) diff --git a/SignalServiceKit/src/Util/FeatureFlags.swift b/SignalServiceKit/src/Util/FeatureFlags.swift index 1c272cad73..07ea95bc3b 100644 --- a/SignalServiceKit/src/Util/FeatureFlags.swift +++ b/SignalServiceKit/src/Util/FeatureFlags.swift @@ -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)