From d87f00005175abd39b5fcfe3b885e4939d8d19c5 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Tue, 24 Oct 2017 10:25:17 -0700 Subject: [PATCH] Interactive/Cancelable slide left for details // FREEBIE --- Signal.xcodeproj/project.pbxproj | 8 ++ .../SlideOffAnimatedTransition.swift | 51 ++++++++ .../Cells/ConversationViewCell.h | 2 + .../ConversationView/Cells/OWSMessageCell.m | 13 ++ .../ConversationViewController.m | 123 ++++++++++++++++-- .../DirectionalPanGestureRecognizer.swift | 38 ++++++ 6 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 Signal/src/UserInterface/SlideOffAnimatedTransition.swift create mode 100644 Signal/src/views/DirectionalPanGestureRecognizer.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index dd343bb13..8fc0f8fc1 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -147,6 +147,8 @@ 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; 4521C3C11F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; 4523149C1F7D7F81003A428C /* OWSMessagesBubbleImageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4523149B1F7D7F81003A428C /* OWSMessagesBubbleImageFactory.swift */; }; + 4523149E1F7E916B003A428C /* SlideOffAnimatedTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4523149D1F7E916B003A428C /* SlideOffAnimatedTransition.swift */; }; + 452314A01F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */; }; 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */; }; 452C46901E427E200087B011 /* OutboundCallInitiator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */; }; 452D1EE81DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */; }; @@ -615,6 +617,8 @@ 4520D8D41D417D8E00123472 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldHelper.swift; sourceTree = ""; }; 4523149B1F7D7F81003A428C /* OWSMessagesBubbleImageFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMessagesBubbleImageFactory.swift; sourceTree = ""; }; + 4523149D1F7E916B003A428C /* SlideOffAnimatedTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SlideOffAnimatedTransition.swift; path = UserInterface/SlideOffAnimatedTransition.swift; sourceTree = ""; }; + 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectionalPanGestureRecognizer.swift; sourceTree = ""; }; 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundCallInitiator.swift; sourceTree = ""; }; 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = ""; }; 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = ""; }; @@ -1211,6 +1215,7 @@ 34B3F8331E8DF1700035BE1A /* ViewControllers */, 76EB052B18170B33006006FC /* Views */, 4523149B1F7D7F81003A428C /* OWSMessagesBubbleImageFactory.swift */, + 4523149D1F7E916B003A428C /* SlideOffAnimatedTransition.swift */, ); name = UserInterface; sourceTree = ""; @@ -1519,6 +1524,7 @@ 45A6DAD51EBBF85500893231 /* ReminderView.swift */, 450D19111F85236600970622 /* RemoteVideoView.h */, 450D19121F85236600970622 /* RemoteVideoView.m */, + 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */, ); name = Views; path = views; @@ -2283,6 +2289,7 @@ 76EB068618170B34006006FC /* ContactTableViewCell.m in Sources */, 3497DBEF1ECE2E4700DB2605 /* DomainFrontingCountryViewController.m in Sources */, 34B3F8881E8DF1700035BE1A /* OversizeTextMessageViewController.swift in Sources */, + 452314A01F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 34B3F8A21E8EA6040035BE1A /* ViewControllerUtils.m in Sources */, 34CA1C271F7156F300E51C51 /* MessageMetadataViewController.swift in Sources */, @@ -2317,6 +2324,7 @@ 34B3F8911E8DF1710035BE1A /* ShowGroupMembersViewController.m in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, 45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */, + 4523149E1F7E916B003A428C /* SlideOffAnimatedTransition.swift in Sources */, 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */, 45F170BB1E2FC5D3003FC1F2 /* CallAudioService.swift in Sources */, 34B3F8711E8DF1700035BE1A /* AboutTableViewController.m in Sources */, diff --git a/Signal/src/UserInterface/SlideOffAnimatedTransition.swift b/Signal/src/UserInterface/SlideOffAnimatedTransition.swift new file mode 100644 index 000000000..ce2a73218 --- /dev/null +++ b/Signal/src/UserInterface/SlideOffAnimatedTransition.swift @@ -0,0 +1,51 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import UIKit + +@objc +class SlideOffAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning { + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + + let containerView = transitionContext.containerView + guard let fromView = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)?.view else { + owsFail("No fromView") + return + } + guard let toView = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)?.view else { + owsFail("No toView") + return + } + + let width = containerView.frame.width + let offsetLeft = fromView.frame.offsetBy(dx: -width, dy: 0) + toView.frame = fromView.frame + + fromView.layer.shadowRadius = 15.0 + fromView.layer.shadowOpacity = 1.0 + toView.layer.opacity = 0.9 + + containerView.insertSubview(toView, belowSubview: fromView) + UIView.animate(withDuration: transitionDuration(using: transitionContext), delay:0, options: .curveLinear, animations: { + fromView.frame = offsetLeft + + toView.layer.opacity = 1.0 + fromView.layer.shadowOpacity = 0.1 + }, completion: { _ in + toView.layer.opacity = 1.0 + toView.layer.shadowOpacity = 0 + + fromView.layer.opacity = 1.0 + fromView.layer.shadowOpacity = 0 + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.3 + } + +} diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h index 4aa57a681..a94f6b944 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h @@ -24,6 +24,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)didTapFailedIncomingAttachment:(ConversationViewItem *)viewItem attachmentPointer:(TSAttachmentPointer *)attachmentPointer; - (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message; +- (void)didPanWithGestureRecognizer:(UIPanGestureRecognizer *)gestureRecognizer + viewItem:(ConversationViewItem *)conversationItem; - (void)showMetadataViewForMessage:(TSMessage *)message; diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index 712a1d866..234e0b5fa 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -162,6 +162,12 @@ NS_ASSUME_NONNULL_BEGIN UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)]; [self addGestureRecognizer:longPress]; + + PanDirectionGestureRecognizer *panGesture = + [[PanDirectionGestureRecognizer alloc] initWithDirection:PanDirectionHorizontal + target:self + action:@selector(handlePanGesture:)]; + [self addGestureRecognizer:panGesture]; } + (NSString *)cellReuseIdentifier @@ -1029,6 +1035,13 @@ NS_ASSUME_NONNULL_BEGIN } } +- (void)handlePanGesture:(UIPanGestureRecognizer *)panRecognizer +{ + OWSAssert(self.delegate); + + [self.delegate didPanWithGestureRecognizer:panRecognizer viewItem:self.viewItem]; +} + #pragma mark - UIMenuController - (void)showMenuController:(CGPoint)fromLocation diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index a39f619c3..19d3e84f7 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -136,6 +136,10 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { ConversationInputToolbarDelegate, GifPickerViewControllerDelegate> +// Show message info animation +@property (nullable, nonatomic) UIPercentDrivenInteractiveTransition *showMessageDetailsTransition; +@property (nullable, nonatomic) UIPanGestureRecognizer *currentShowMessageDetailsPanGesture; + @property (nonatomic) TSThread *thread; @property (nonatomic) YapDatabaseConnection *editingDatabaseConnection; @@ -988,9 +992,14 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [super viewWillDisappear:animated]; - self.isViewVisible = NO; - [self.inputToolbar viewWillDisappear:animated]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + self.userHasScrolled = NO; + self.isViewVisible = NO; [self.audioAttachmentPlayer stop]; self.audioAttachmentPlayer = nil; @@ -1005,12 +1014,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { self.isUserScrolling = NO; } -- (void)viewDidDisappear:(BOOL)animated -{ - [super viewDidDisappear:animated]; - self.userHasScrolled = NO; -} - #pragma mark - Initiliazers - (void)setNavigationTitle @@ -4036,6 +4039,110 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { return cell; } +#pragma mark - swipe to show message details + +- (void)didPanWithGestureRecognizer:(UIPanGestureRecognizer *)gestureRecognizer + viewItem:(ConversationViewItem *)conversationItem +{ + self.currentShowMessageDetailsPanGesture = gestureRecognizer; + + const CGFloat leftTranslation = -1 * [gestureRecognizer translationInView:self.view].x; + const CGFloat percent = MAX(leftTranslation, 0) / self.view.frame.size.width; + + switch (gestureRecognizer.state) { + case UIGestureRecognizerStateBegan: { + TSInteraction *interaction = conversationItem.interaction; + if ([interaction isKindOfClass:[TSIncomingMessage class]] || + [interaction isKindOfClass:[TSOutgoingMessage class]]) { + + // Canary check in case we later have another reason to set navigationController.delegate - we don't + // want to inadvertently clobber it here. + OWSAssert(self.navigationController.delegate == nil) self.navigationController.delegate = self; + TSMessage *message = (TSMessage *)interaction; + MessageMetadataViewController *view = [[MessageMetadataViewController alloc] initWithMessage:message]; + [self.navigationController pushViewController:view animated:YES]; + } else { + OWSFail(@"%@ Can't show message metadata for message of type: %@", self.tag, [interaction class]); + } + break; + } + case UIGestureRecognizerStateChanged: { + UIPercentDrivenInteractiveTransition *transition = self.showMessageDetailsTransition; + if (!transition) { + DDLogVerbose(@"%@ transition not set up yet", self.tag); + return; + } + [transition updateInteractiveTransition:percent]; + break; + } + case UIGestureRecognizerStateEnded: { + const CGFloat velocity = [gestureRecognizer velocityInView:self.view].x; + + UIPercentDrivenInteractiveTransition *transition = self.showMessageDetailsTransition; + if (!transition) { + DDLogVerbose(@"%@ transition not set up yet", self.tag); + return; + } + + // Complete the transition if moved sufficiently far or fast + // Note this is trickier for incoming, since you are already on the left, and have less space. + if (percent > 0.3 || velocity < -800) { + [transition finishInteractiveTransition]; + } else { + [transition cancelInteractiveTransition]; + } + break; + } + case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: { + UIPercentDrivenInteractiveTransition *transition = self.showMessageDetailsTransition; + if (!transition) { + DDLogVerbose(@"%@ transition not set up yet", self.tag); + return; + } + + [transition cancelInteractiveTransition]; + break; + } + default: + break; + } +} + +- (nullable id)navigationController: + (UINavigationController *)navigationController + animationControllerForOperation:(UINavigationControllerOperation)operation + fromViewController:(UIViewController *)fromVC + toViewController:(UIViewController *)toVC +{ + return [SlideOffAnimatedTransition new]; +} + +- (nullable id) + navigationController:(UINavigationController *)navigationController +interactionControllerForAnimationController:(id)animationController +{ + // We needed to be the navigation controller delegate to specify the interactive "slide left for message details" + // animation But we may not want to be the navigation controller delegate permanently. + self.navigationController.delegate = nil; + + DDLogInfo(@"%@ >>>> in %s", self.tag, __PRETTY_FUNCTION__); + UIPanGestureRecognizer *recognizer = self.currentShowMessageDetailsPanGesture; + if (recognizer == nil) { + OWSFail(@"currentShowMessageDetailsPanGesture was unexpectedly nil"); + return nil; + } + + if (recognizer.state == UIGestureRecognizerStateBegan) { + self.showMessageDetailsTransition = [UIPercentDrivenInteractiveTransition new]; + self.showMessageDetailsTransition.completionCurve = UIViewAnimationCurveEaseOut; + } else { + self.showMessageDetailsTransition = nil; + } + + return self.showMessageDetailsTransition; +} + #pragma mark - UICollectionViewDelegate - (void)collectionView:(UICollectionView *)collectionView diff --git a/Signal/src/views/DirectionalPanGestureRecognizer.swift b/Signal/src/views/DirectionalPanGestureRecognizer.swift new file mode 100644 index 000000000..696a30a87 --- /dev/null +++ b/Signal/src/views/DirectionalPanGestureRecognizer.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import UIKit.UIGestureRecognizerSubclass + +@objc +enum PanDirection: Int { + case vertical + case horizontal +} + +@objc +class PanDirectionGestureRecognizer: UIPanGestureRecognizer { + + let direction: PanDirection + + init(direction: PanDirection, target: AnyObject, action: Selector) { + self.direction = direction + super.init(target: target, action: action) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if state == .began { + let vel = velocity(in: view) + switch direction { + case .horizontal where fabs(vel.y) > fabs(vel.x): + state = .cancelled + case .vertical where fabs(vel.x) > fabs(vel.y): + state = .cancelled + default: + break + } + } + } +}