Merge branch 'mkirk/input-accessory-view' into hotfix/2.19.4

This commit is contained in:
Michael Kirk 2018-01-08 16:32:54 -05:00
commit 2b81d45530
21 changed files with 1410 additions and 749 deletions

View File

@ -44,7 +44,7 @@
34B3F8791E8DF1700035BE1A /* CountryCodeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8411E8DF1700035BE1A /* CountryCodeViewController.m */; };
34B3F87B1E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8441E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift */; };
34B3F87C1E8DF1700035BE1A /* FingerprintViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */; };
34B3F87D1E8DF1700035BE1A /* FullImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8481E8DF1700035BE1A /* FullImageViewController.m */; };
34B3F87D1E8DF1700035BE1A /* MediaDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */; };
34B3F87E1E8DF1700035BE1A /* InboxTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */; };
34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */; };
34B3F8811E8DF1700035BE1A /* LockInteractionController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F84E1E8DF1700035BE1A /* LockInteractionController.m */; };
@ -160,6 +160,7 @@
452EA09E1EA7ABE00078744B /* AttachmentPointerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */; };
452ECA4D1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; };
452ECA4E1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; };
453034AB200289F50018945D /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453034AA200289F50018945D /* VideoPlayerView.swift */; };
45360B8D1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; };
45360B8E1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; };
45360B901F9527DA00FA666C /* SearcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8F1F9527DA00FA666C /* SearcherTest.swift */; };
@ -466,8 +467,8 @@
34B3F8441E8DF1700035BE1A /* ExperienceUpgradesPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradesPageViewController.swift; sourceTree = "<group>"; };
34B3F8451E8DF1700035BE1A /* FingerprintViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FingerprintViewController.h; sourceTree = "<group>"; };
34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FingerprintViewController.m; sourceTree = "<group>"; };
34B3F8471E8DF1700035BE1A /* FullImageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FullImageViewController.h; sourceTree = "<group>"; };
34B3F8481E8DF1700035BE1A /* FullImageViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullImageViewController.m; sourceTree = "<group>"; };
34B3F8471E8DF1700035BE1A /* MediaDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MediaDetailViewController.h; sourceTree = "<group>"; };
34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaDetailViewController.m; sourceTree = "<group>"; };
34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InboxTableViewCell.h; sourceTree = "<group>"; };
34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InboxTableViewCell.m; sourceTree = "<group>"; };
34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteFlow.swift; sourceTree = "<group>"; };
@ -633,6 +634,7 @@
452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = "<group>"; };
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = "<group>"; };
452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MessageFetcherJob.swift; path = Jobs/MessageFetcherJob.swift; sourceTree = "<group>"; };
453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
45360B8C1F9521F800FA666C /* Searcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Searcher.swift; sourceTree = "<group>"; };
45360B8F1F9527DA00FA666C /* SearcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearcherTest.swift; sourceTree = "<group>"; };
45387B021E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWS102MoveLoggingPreferenceToUserDefaults.h; path = Migrations/OWS102MoveLoggingPreferenceToUserDefaults.h; sourceTree = "<group>"; };
@ -1027,8 +1029,8 @@
34B3F8461E8DF1700035BE1A /* FingerprintViewController.m */,
34E8BF361EE9E2FD00F5F4CA /* FingerprintViewScanController.h */,
34E8BF371EE9E2FD00F5F4CA /* FingerprintViewScanController.m */,
34B3F8471E8DF1700035BE1A /* FullImageViewController.h */,
34B3F8481E8DF1700035BE1A /* FullImageViewController.m */,
34B3F8471E8DF1700035BE1A /* MediaDetailViewController.h */,
34B3F8481E8DF1700035BE1A /* MediaDetailViewController.m */,
34BECE2C1F7ABCE000D7438D /* GifPicker */,
34B3F86F1E8DF1700035BE1A /* HomeViewController.h */,
34B3F8701E8DF1700035BE1A /* HomeViewController.m */,
@ -1507,6 +1509,7 @@
76EB052B18170B33006006FC /* Views */ = {
isa = PBXGroup;
children = (
453034AA200289F50018945D /* VideoPlayerView.swift */,
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */,
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
@ -2327,6 +2330,7 @@
45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */,
45638BDF1F3DDB2200128435 /* MessageSender+Promise.swift in Sources */,
34535D821E256BE9008A4747 /* UIView+OWS.m in Sources */,
453034AB200289F50018945D /* VideoPlayerView.swift in Sources */,
45F3AEB61DFDE7900080CE33 /* AvatarImageView.swift in Sources */,
7038632718F70C0700D4A43F /* CryptoTools.m in Sources */,
45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */,
@ -2385,7 +2389,7 @@
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */,
45A663C51F92EC760027B59E /* GroupTableViewCell.swift in Sources */,
34B3F87D1E8DF1700035BE1A /* FullImageViewController.m in Sources */,
34B3F87D1E8DF1700035BE1A /* MediaDetailViewController.m in Sources */,
45666F7B1D9C0533008FE134 /* OWSDatabaseMigration.m in Sources */,
34D1F0861F8678AA0066283D /* ConversationViewController.m in Sources */,
B90418E6183E9DD40038554A /* DateUtil.m in Sources */,

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "VideoPlayer_Slider_Thumb_15x15_@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "AppSettingsViewController.h"
@ -10,7 +10,7 @@
#import "DebugUIPage.h"
#import "Environment.h"
#import "FingerprintViewController.h"
#import "FullImageViewController.h"
#import "MediaDetailViewController.h"
#import "HomeViewController.h"
#import "NSString+OWS.h"
#import "NotificationsManager.h"

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import <PureLayout/PureLayout.h>
@ -83,6 +83,9 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
- (NSLayoutConstraint *)autoPinLeadingToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTrailingToSuperview;
- (NSLayoutConstraint *)autoPinTrailingToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTopToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinBottomToSuperviewWithMargin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view;
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view margin:(CGFloat)margin;
- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view;

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "UIView+OWS.h"
@ -299,6 +299,32 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value)
}
}
- (NSLayoutConstraint *)autoPinBottomToSuperviewWithMargin:(CGFloat)margin
{
if (@available(iOS 9.0, *)) {
NSLayoutConstraint *constraint =
[self.bottomAnchor constraintEqualToAnchor:self.superview.layoutMarginsGuide.bottomAnchor
constant:-margin];
constraint.active = YES;
return constraint;
} else {
return [self autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:margin];
}
}
- (NSLayoutConstraint *)autoPinTopToSuperviewWithMargin:(CGFloat)margin
{
if (@available(iOS 9.0, *)) {
NSLayoutConstraint *constraint =
[self.topAnchor constraintEqualToAnchor:self.superview.layoutMarginsGuide.topAnchor
constant:margin];
constraint.active = YES;
return constraint;
} else {
return [self autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:margin];
}
}
- (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view
{
OWSAssert(view);

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -18,7 +18,9 @@ NS_ASSUME_NONNULL_BEGIN
- (void)didTapImageViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream;
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream;
- (void)didTapTruncatedTextMessage:(ConversationViewItem *)conversationItem;
- (void)didTapFailedIncomingAttachment:(ConversationViewItem *)viewItem

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageCell.h"
@ -1172,7 +1172,9 @@ NS_ASSUME_NONNULL_BEGIN
[self.delegate didTapAudioViewItem:self.viewItem attachmentStream:self.attachmentStream];
return;
case OWSMessageCellType_Video:
[self.delegate didTapVideoViewItem:self.viewItem attachmentStream:self.attachmentStream];
[self.delegate didTapVideoViewItem:self.viewItem
attachmentStream:self.attachmentStream
imageView:self.stillImageView];
return;
case OWSMessageCellType_GenericAttachment:
[AttachmentSharing showShareUIForAttachment:self.attachmentStream];

View File

@ -26,6 +26,8 @@ NS_ASSUME_NONNULL_BEGIN
- (void)didApproveAttachment:(SignalAttachment *)attachment;
- (void)toolbarHeightDidChange:(CGFloat)newHeight;
@end
#pragma mark -

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationInputToolbar.h"
@ -15,7 +15,7 @@
NS_ASSUME_NONNULL_BEGIN
static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext;
static const CGFloat ConversationInputToolbarBorderViewHeight = 0.5;
@interface ConversationInputToolbar () <UIGestureRecognizerDelegate, ConversationTextViewToolbarDelegate>
@property (nonatomic, readonly) UIView *contentView;
@ -30,6 +30,8 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
@property (nonatomic) NSArray<NSLayoutConstraint *> *contentContraints;
@property (nonatomic) NSValue *lastTextContentSize;
@property (nonatomic) CGFloat toolbarHeight;
@property (nonatomic) CGFloat textViewHeight;
#pragma mark - Voice Memo Recording UI
@ -68,18 +70,25 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
[self removeKVOObservers];
}
- (CGSize)intrinsicContentSize
{
CGSize newSize = CGSizeMake(self.bounds.size.width, self.toolbarHeight + ConversationInputToolbarBorderViewHeight);
return newSize;
}
- (void)createContents
{
self.layoutMargins = UIEdgeInsetsZero;
self.backgroundColor = [UIColor ows_inputToolbarBackgroundColor];
self.autoresizingMask = UIViewAutoresizingFlexibleHeight;
UIView *borderView = [UIView new];
borderView.backgroundColor = [UIColor colorWithWhite:238 / 255.f alpha:1.f];
[self addSubview:borderView];
[borderView autoPinWidthToSuperview];
[borderView autoPinEdgeToSuperviewEdge:ALEdgeTop];
[borderView autoSetDimension:ALDimensionHeight toSize:0.5f];
[borderView autoSetDimension:ALDimensionHeight toSize:ConversationInputToolbarBorderViewHeight];
_contentView = [UIView containerView];
[self addSubview:self.contentView];
@ -223,12 +232,16 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
const CGFloat kMinTextViewHeight = ceil(self.inputTextView.font.lineHeight
+ self.inputTextView.textContainerInset.top + self.inputTextView.textContainerInset.bottom
+ self.inputTextView.contentInset.top + self.inputTextView.contentInset.bottom);
const CGFloat kMaxTextViewHeight = 100.f;
// Exactly 4 lines of text with default sizing.
const CGFloat kMaxTextViewHeight = 98.f;
const CGFloat textViewDesiredHeight = (self.inputTextView.contentSize.height + self.inputTextView.contentInset.top
+ self.inputTextView.contentInset.bottom);
const CGFloat textViewHeight = ceil(Clamp(textViewDesiredHeight, kMinTextViewHeight, kMaxTextViewHeight));
const CGFloat kMinContentHeight = kMinTextViewHeight + textViewVInset * 2;
self.textViewHeight = textViewHeight;
self.toolbarHeight = textViewHeight + textViewVInset * 2;
if (self.attachmentToApprove) {
OWSAssert(self.attachmentView);
@ -247,14 +260,14 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
self.contentContraints = @[
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.attachmentView autoPinBottomToSuperviewWithMargin:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:contentHInset],
[self.attachmentView autoSetDimension:ALDimensionHeight toSize:150.f],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.attachmentView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.rightButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
@ -316,7 +329,7 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
self.contentContraints = @[
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeLeft],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.leftButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[leftButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[leftButton autoPinLeadingToSuperviewWithMargin:contentHInset],
@ -325,18 +338,18 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
[self.inputTextView autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.leftButtonWrapper],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.inputTextView autoPinBottomToSuperviewWithMargin:textViewVInset],
[self.inputTextView autoSetDimension:ALDimensionHeight toSize:textViewHeight],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.inputTextView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.rightButtonWrapper autoPinBottomToSuperviewWithMargin:0],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
[rightButton autoPinTrailingToSuperviewWithMargin:contentHInset],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom]
];
// Layout immediately, unless the input toolbar hasn't even been laid out yet.
@ -709,6 +722,7 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
if (!lastTextContentSize || fabs(lastTextContentSize.CGSizeValue.width - textContentSize.width) > 0.1f
|| fabs(lastTextContentSize.CGSizeValue.height - textContentSize.height) > 0.1f) {
[self ensureContentConstraints];
[self invalidateIntrinsicContentSize];
}
}
}
@ -811,8 +825,6 @@ static void *kConversationInputTextViewObservingContext = &kConversationInputTex
- (void)viewWillDisappear:(BOOL)animated
{
[self.attachmentView viewWillDisappear:animated];
[self endEditingTextMessage];
}
- (nullable NSString *)textInputPrimaryLanguage

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewController.h"
@ -19,7 +19,7 @@
#import "DebugUITableViewController.h"
#import "Environment.h"
#import "FingerprintViewController.h"
#import "FullImageViewController.h"
#import "MediaDetailViewController.h"
#import "NSAttributedString+OWS.h"
#import "NSString+OWS.h"
#import "NewGroupViewController.h"
@ -64,7 +64,6 @@
#import <JSQMessagesViewController/JSQSystemSoundPlayer+JSQMessages.h>
#import <JSQMessagesViewController/UIColor+JSQMessages.h>
#import <JSQSystemSoundPlayer.h>
#import <MediaPlayer/MediaPlayer.h>
#import <MobileCoreServices/UTCoreTypes.h>
#import <SignalServiceKit/ContactsUpdater.h>
#import <SignalServiceKit/MimeTypeUtil.h>
@ -170,7 +169,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
@property (nonatomic) NSArray<ConversationViewItem *> *viewItems;
@property (nonatomic) NSMutableDictionary<NSString *, ConversationViewItem *> *viewItemCache;
@property (nonatomic, nullable) MPMoviePlayerController *videoPlayer;
@property (nonatomic, nullable) AVAudioRecorder *audioRecorder;
@property (nonatomic, nullable) OWSAudioAttachmentPlayer *audioAttachmentPlayer;
@property (nonatomic, nullable) NSUUID *voiceMessageUUID;
@ -215,17 +213,18 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
@property (nonatomic, readonly) BOOL isGroupConversation;
@property (nonatomic) BOOL isUserScrolling;
@property (nonatomic) NSLayoutConstraint *scrollDownButtonButtomConstraint;
@property (nonatomic) ConversationScrollButton *scrollDownButton;
#ifdef DEBUG
@property (nonatomic) ConversationScrollButton *scrollUpButton;
#endif
@property (nonatomic) BOOL isViewCompletelyAppeared;
@property (nonatomic) BOOL isViewVisible;
@property (nonatomic) BOOL isAppInBackground;
@property (nonatomic) BOOL shouldObserveDBModifications;
@property (nonatomic) BOOL viewHasEverAppeared;
@property (nonatomic) BOOL wasScrolledToBottomBeforeKeyboardShow;
@property (nonatomic) BOOL wasScrolledToBottomBeforeLayoutChange;
@property (nonatomic) BOOL hasUnreadMessages;
@end
@ -319,6 +318,10 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
selector:@selector(signalAccountsDidChange:)
name:OWSContactsManagerSignalAccountsDidChangeNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillChangeFrame:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
}
- (void)signalAccountsDidChange:(NSNotification *)notification
@ -452,13 +455,13 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
{
if (_peek) {
self.inputToolbar.hidden = YES;
[self.inputToolbar endEditing:TRUE];
[self dismissKeyBoard];
return;
}
if (self.userLeftGroup) {
self.inputToolbar.hidden = YES; // user has requested they leave the group. further sends disallowed
[self.inputToolbar endEditing:TRUE];
[self dismissKeyBoard];
} else {
self.inputToolbar.hidden = NO;
}
@ -501,6 +504,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
self.collectionView.dataSource = self;
self.collectionView.showsVerticalScrollIndicator = YES;
self.collectionView.showsHorizontalScrollIndicator = NO;
self.collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
self.collectionView.backgroundColor = [UIColor whiteColor];
[self.view addSubview:self.collectionView];
[self.collectionView autoPinWidthToSuperview];
@ -517,10 +521,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
_inputToolbar = [ConversationInputToolbar new];
self.inputToolbar.inputToolbarDelegate = self;
self.inputToolbar.inputTextViewDelegate = self;
[self.view addSubview:self.inputToolbar];
[self.inputToolbar autoPinWidthToSuperview];
[self.inputToolbar autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.collectionView];
[self autoPinViewToBottomGuideOrKeyboard:self.inputToolbar];
[self.collectionView autoPinToBottomLayoutGuideOfViewController:self withInset:0];
self.loadMoreHeader = [UILabel new];
self.loadMoreHeader.text = NSLocalizedString(@"CONVERSATION_VIEW_LOADING_MORE_MESSAGES",
@ -534,6 +535,16 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.loadMoreHeader autoSetDimension:ALDimensionHeight toSize:kLoadMoreHeaderHeight];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
- (nullable UIView *)inputAccessoryView
{
return self.inputToolbar;
}
- (void)registerCellClasses
{
[self.collectionView registerClass:[OWSSystemMessageCell class]
@ -883,6 +894,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:dismissAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
}
@ -999,6 +1011,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
_callOnOpen = NO;
}
self.isViewCompletelyAppeared = YES;
self.viewHasEverAppeared = YES;
}
@ -1012,6 +1025,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[super viewWillDisappear:animated];
self.isViewCompletelyAppeared = NO;
[self.inputToolbar viewWillDisappear:animated];
}
@ -1029,7 +1043,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self markVisibleMessagesAsRead];
[self cancelVoiceMemo];
[self.cellMediaCache removeAllObjects];
[self.inputToolbar endEditingTextMessage];
self.isUserScrolling = NO;
}
@ -1388,11 +1401,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
return;
}
// The JSQ event listeners cause a bounce animation, so we temporarily disable them.
[self setShouldIgnoreKeyboardChanges:YES];
[self dismissKeyBoard];
[self popKeyBoard];
[self setShouldIgnoreKeyboardChanges:NO];
}
#pragma mark - Dynamic Text
@ -1635,6 +1645,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[actionSheetController addAction:resendMessageAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1669,6 +1680,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[actionSheetController addAction:resendMessageAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1796,6 +1808,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[alertController addAction:resetSessionAction];
[self dismissKeyBoard];
[self presentViewController:alertController animated:YES completion:nil];
}
@ -1836,6 +1849,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:acceptSafetyNumberAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1865,9 +1879,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[alertController addAction:callAction];
[alertController addAction:[OWSAlerts cancelAction]];
[[UIApplication sharedApplication].frontmostViewController presentViewController:alertController
animated:YES
completion:nil];
[self dismissKeyBoard];
[self presentViewController:alertController animated:YES completion:nil];
}
#pragma mark - ConversationViewCellDelegate
@ -1916,6 +1929,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}];
[actionSheetController addAction:blockAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:YES completion:nil];
}
@ -1979,46 +1993,32 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
OWSAssert(attachmentStream);
OWSAssert(imageView);
[self dismissKeyBoard];
UIWindow *window = [UIApplication sharedApplication].keyWindow;
CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window];
FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachmentStream:attachmentStream
MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream
fromRect:convertedRect
viewItem:viewItem];
[vc presentFromViewController:self];
}
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream
- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIImageView *)imageView
{
OWSAssert([NSThread isMainThread]);
OWSAssert(viewItem);
OWSAssert(attachmentStream);
NSFileManager *fileManager = [NSFileManager defaultManager];
if (![fileManager fileExistsAtPath:[attachmentStream.mediaURL path]]) {
OWSFail(@"%@ Missing video file: %@", self.logTag, attachmentStream.mediaURL);
}
[self dismissKeyBoard];
self.videoPlayer = [[MPMoviePlayerController alloc] initWithContentURL:attachmentStream.mediaURL];
[_videoPlayer prepareToPlay];
UIWindow *window = [UIApplication sharedApplication].keyWindow;
CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(moviePlayerWillExitFullscreen:)
name:MPMoviePlayerWillExitFullscreenNotification
object:_videoPlayer];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(moviePlayerDidExitFullscreen:)
name:MPMoviePlayerDidExitFullscreenNotification
object:_videoPlayer];
_videoPlayer.controlStyle = MPMovieControlStyleDefault;
_videoPlayer.shouldAutoplay = YES;
[self.view addSubview:_videoPlayer.view];
// We can't animate from the cell media frame;
// MPMoviePlayerController will animate a crop of its
// contents rather than scaling them.
_videoPlayer.view.frame = self.view.bounds;
[_videoPlayer setFullscreen:YES animated:NO];
MediaDetailViewController *vc = [[MediaDetailViewController alloc] initWithAttachmentStream:attachmentStream
fromRect:convertedRect
viewItem:viewItem];
[vc presentFromViewController:self];
}
- (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream
@ -2099,42 +2099,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.navigationController pushViewController:view animated:YES];
}
#pragma mark - Video Playback
// There's more than one way to exit the fullscreen video playback.
// There's a done button, a "toggle fullscreen" button and I think
// there's some gestures too. These fire slightly different notifications.
// We want to hide & clean up the video player immediately in all of
// these cases.
- (void)moviePlayerWillExitFullscreen:(id)sender
{
DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self clearVideoPlayer];
}
// See comment on moviePlayerWillExitFullscreen:
- (void)moviePlayerDidExitFullscreen:(id)sender
{
DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self clearVideoPlayer];
}
- (void)clearVideoPlayer
{
[_videoPlayer stop];
[_videoPlayer.view removeFromSuperview];
self.videoPlayer = nil;
}
- (void)setVideoPlayer:(MPMoviePlayerController *_Nullable)videoPlayer
{
_videoPlayer = videoPlayer;
[ViewControllerUtils setAudioIgnoresHardwareMuteSwitch:videoPlayer != nil];
}
#pragma mark - System Messages
- (void)didTapSystemMessageWithInteraction:(TSInteraction *)interaction
@ -2235,7 +2199,9 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[self.view addSubview:self.scrollDownButton];
[self.scrollDownButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize];
[self.scrollDownButton autoSetDimension:ALDimensionHeight toSize:ConversationScrollButton.buttonSize];
[self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.inputToolbar];
self.scrollDownButtonButtomConstraint =
[self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.collectionView];
[self.scrollDownButton autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
#ifdef DEBUG
@ -2351,6 +2317,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[[UIDocumentMenuViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode];
menuController.delegate = self;
[self dismissKeyBoard];
[self presentViewController:menuController animated:YES completion:nil];
}
@ -2362,6 +2329,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[[GifPickerViewController alloc] initWithThread:self.thread messageSender:self.messageSender];
view.delegate = self;
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:view];
[self dismissKeyBoard];
[self presentViewController:navigationController animated:YES completion:nil];
}
@ -2401,6 +2370,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
// post iOS11, document picker has no blue header.
[UIUtil applyDefaultSystemAppearence];
}
[self dismissKeyBoard];
[self presentViewController:documentPicker animated:YES completion:nil];
}
@ -2497,8 +2468,9 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ];
picker.allowsEditing = NO;
picker.delegate = self;
dispatch_async(dispatch_get_main_queue(), ^{
[self dismissKeyBoard];
[self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]];
});
}];
@ -2518,6 +2490,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
picker.delegate = self;
picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ];
[self dismissKeyBoard];
[self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]];
}
@ -3081,7 +3054,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
const CGFloat kIsAtBottomTolerancePts = 5;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom = MAX(0.f, contentHeight - self.collectionView.bounds.size.height);
CGFloat contentOffsetYBottom
= MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
BOOL isScrolledToBottom = (self.collectionView.contentOffset.y > contentOffsetYBottom - kIsAtBottomTolerancePts);
return isScrolledToBottom;
@ -3274,6 +3248,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
- (void)attachmentButtonPressed
{
[self dismissKeyBoard];
__weak ConversationViewController *weakSelf = self;
if ([self isBlockedContactConversation]) {
@ -3349,6 +3324,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
[gifAction setValue:gifImage forKey:@"image"];
[actionSheetController addAction:gifAction];
[self dismissKeyBoard];
[self presentViewController:actionSheetController animated:true completion:nil];
}
@ -3658,6 +3634,95 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
});
}
- (void)keyboardWillChangeFrame:(NSNotification *)notification
{
// `willChange` is the correct keyboard notifiation to observe when adjusting contentInset
// in lockstep with the keyboard presentation animation. `didChange` results in the contentInset
// not adjusting until after the keyboard is fully up.
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
[self handleKeyboardNotification:notification];
}
- (void)handleKeyboardNotification:(NSNotification *)notification
{
AssertIsOnMainThread();
NSDictionary *userInfo = [notification userInfo];
NSValue *_Nullable keyboardBeginFrameValue = userInfo[UIKeyboardFrameBeginUserInfoKey];
if (!keyboardBeginFrameValue) {
OWSFail(@"%@ Missing keyboard begin frame", self.logTag);
return;
}
NSValue *_Nullable keyboardEndFrameValue = userInfo[UIKeyboardFrameEndUserInfoKey];
if (!keyboardEndFrameValue) {
OWSFail(@"%@ Missing keyboard end frame", self.logTag);
return;
}
CGRect keyboardEndFrame = [keyboardEndFrameValue CGRectValue];
UIEdgeInsets oldInsets = self.collectionView.contentInset;
UIEdgeInsets newInsets = oldInsets;
// bottomLayoutGuide accounts for extra offset needed on iPhoneX
newInsets.bottom = keyboardEndFrame.size.height - self.bottomLayoutGuide.length;
BOOL wasScrolledToBottom = [self isScrolledToBottom];
void (^adjustInsets)(void) = ^(void) {
self.collectionView.contentInset = newInsets;
self.collectionView.scrollIndicatorInsets = newInsets;
// Note there is a bug in iOS11.2 which where switching to the emoji keyboard
// does not fire a UIKeyboardFrameWillChange notification. In that case, the scroll
// down button gets mostly obscured by the keyboard.
// RADAR: #36297652
self.scrollDownButtonButtomConstraint.constant = -1 * newInsets.bottom;
[self.scrollDownButton setNeedsLayout];
[self.scrollDownButton layoutIfNeeded];
// HACK: I've made the assumption that we are already in the context of an animation, in which case the
// above should be sufficient to smoothly move the scrollDown button in step with the keyboard presentation
// animation. Yet, setting the constraint doesn't animate the movement of the button - it "jumps" to it's final
// position. So here we manually lay out the scroll down button frame (seemingly redundantly), which allows it
// to be smoothly animated.
CGRect newButtonFrame = self.scrollDownButton.frame;
newButtonFrame.origin.y
= self.scrollDownButton.superview.height - (newInsets.bottom + self.scrollDownButton.height);
self.scrollDownButton.frame = newButtonFrame;
// Adjust content offset to prevent the presented keyboard from obscuring content.
if (wasScrolledToBottom) {
// If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom.
[self scrollToBottomAnimated:NO];
} else {
// If we were scrolled away from the bottom, shift the content in lockstep with the
// keyboard, up to the limits of the content bounds.
CGFloat insetChange = newInsets.bottom - oldInsets.bottom;
CGFloat oldYOffset = self.collectionView.contentOffset.y;
CGFloat newYOffset = Clamp(oldYOffset + insetChange, 0, self.safeContentHeight);
CGPoint newOffset = CGPointMake(0, newYOffset);
// If the user is dismissing the keyboard via interactive scrolling, any additional conset offset feels
// redundant, so we only adjust content offset when *presenting* the keyboard.
if (insetChange > 0 && newYOffset > keyboardEndFrame.origin.y) {
[self.collectionView setContentOffset:newOffset animated:NO];
}
}
};
if (self.isViewCompletelyAppeared) {
adjustInsets();
} else {
// Even though we are scrolling without explicitly animating, the notification seems to occur within the context
// of a system animation, which is desirable when the view is visible, because the user sees the content rise
// in sync with the keyboard. However, when the view hasn't yet been presented, the animation conflicts and the
// result is that initial load causes the collection cells to visably "animate" to their final position once the
// view appears.
[UIView performWithoutAnimation:adjustInsets];
}
}
- (void)didApproveAttachment:(SignalAttachment *)attachment
{
OWSAssert(attachment);
@ -3690,13 +3755,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
return [self.collectionView.collectionViewLayout collectionViewContentSize].height;
}
- (void)scrollToBottomImmediately
{
OWSAssert([NSThread isMainThread]);
[self scrollToBottomAnimated:NO];
}
- (void)scrollToBottomAnimated:(BOOL)animated
{
OWSAssert([NSThread isMainThread]);
@ -3706,9 +3764,11 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
}
CGFloat contentHeight = self.safeContentHeight;
CGFloat dstY = MAX(0, contentHeight - self.collectionView.height);
[self.collectionView setContentOffset:CGPointMake(0, dstY) animated:animated];
CGFloat dstY
= MAX(0, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
[self.collectionView setContentOffset:CGPointMake(0, dstY) animated:NO];
[self didScrollToBottom];
}
@ -3718,22 +3778,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
{
[self updateLastVisibleTimestamp];
[self autoLoadMoreIfNecessary];
if (self.isUserScrolling && [self isScrolledAwayFromBottom]) {
[self.inputToolbar endEditingTextMessage];
}
}
// See the comments on isScrolledToBottom.
- (BOOL)isScrolledAwayFromBottom
{
CGFloat contentHeight = self.safeContentHeight;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom = MAX(0.f, contentHeight - self.collectionView.bounds.size.height);
const CGFloat kThreshold = 250;
BOOL isScrolledAwayFromBottom = (self.collectionView.contentOffset.y < contentOffsetYBottom - kThreshold);
return isScrolledAwayFromBottom;
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
@ -4034,8 +4078,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
- (void)collectionViewWillChangeLayout
{
OWSAssert([NSThread isMainThread]);
self.wasScrolledToBottomBeforeLayoutChange = [self isScrolledToBottom];
}
- (void)collectionViewDidChangeLayout
@ -4043,15 +4085,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) {
OWSAssert([NSThread isMainThread]);
[self updateLastVisibleTimestamp];
// JSQMessageView has glitchy behavior. When presenting/dismissing view
// controllers, the size of the input toolbar and/or collection view can
// repeatedly change, leaving scroll state in an invalid state. The
// simplest fix that covers most cases is to ensure that we remain
// "scrolled to bottom" across these changes.
if (self.wasScrolledToBottomBeforeLayoutChange) {
[self scrollToBottomImmediately];
}
}
#pragma mark - View Items

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewLayout.h"
@ -68,6 +68,7 @@ NS_ASSUME_NONNULL_BEGIN
[self clearState];
return;
}
if (self.collectionView.bounds.size.width <= 0.f || self.collectionView.bounds.size.height <= 0.f) {
OWSFail(
@"%@ Collection view has invalid size: %@", self.logTag, NSStringFromCGRect(self.collectionView.bounds));

View File

@ -1,553 +0,0 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "FullImageViewController.h"
#import "AttachmentSharing.h"
#import "ConversationViewItem.h"
#import "Signal-Swift.h"
#import "TSAttachmentStream.h"
#import "TSInteraction.h"
#import "UIColor+OWS.h"
#import "UIUtil.h"
#import "UIView+OWS.h"
#import <SignalServiceKit/NSData+Image.h>
#import <YYImage/YYImage.h>
NS_ASSUME_NONNULL_BEGIN
#define kMinZoomScale 1.0f
#define kMaxZoomScale 8.0f
#define kBackgroundAlpha 0.6f
// In order to use UIMenuController, the view from which it is
// presented must have certain custom behaviors.
@interface AttachmentMenuView : UIView
@end
#pragma mark -
@implementation AttachmentMenuView
- (BOOL)canBecomeFirstResponder {
return YES;
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return NO;
}
@end
#pragma mark -
@interface FullImageViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
@property (nonatomic) UIView *backgroundView;
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIImageView *imageView;
@property (nonatomic) UIButton *shareButton;
@property (nonatomic) UIView *contentView;
@property (nonatomic) CGRect originRect;
@property (nonatomic) BOOL isPresenting;
@property (nonatomic) NSData *fileData;
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, nullable) SignalAttachment *attachment;
@property (nonatomic, nullable) ConversationViewItem *viewItem;
@property (nonatomic) UIToolbar *footerBar;
@end
@implementation FullImageViewController
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
fromRect:(CGRect)rect
viewItem:(ConversationViewItem *_Nullable)viewItem
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachmentStream = attachmentStream;
self.originRect = rect;
self.viewItem = viewItem;
}
return self;
}
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachment = attachment;
self.originRect = rect;
}
return self;
}
- (NSURL *_Nullable)attachmentUrl
{
if (self.attachmentStream) {
return self.attachmentStream.mediaURL;
} else if (self.attachment) {
return self.attachment.dataUrl;
} else {
return nil;
}
}
- (NSData *)fileData
{
if (!_fileData) {
NSURL *_Nullable url = self.attachmentUrl;
if (url) {
_fileData = [NSData dataWithContentsOfURL:url];
}
}
return _fileData;
}
- (UIImage *)image {
if (self.attachmentStream) {
return self.attachmentStream.image;
} else if (self.attachment) {
return self.attachment.image;
} else {
return nil;
}
}
- (BOOL)isAnimated
{
if (self.attachmentStream) {
return self.attachmentStream.isAnimated;
} else if (self.attachment) {
return self.attachment.isAnimatedImage;
} else {
return NO;
}
}
- (void)loadView {
self.view = [AttachmentMenuView new];
self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
}
- (void)viewDidLoad {
[super viewDidLoad];
[self initializeBackground];
[self initializeContentViewAndFooterBar];
[self initializeScrollView];
[self initializeImageView];
[self initializeGestureRecognizers];
[self populateImageView:self.image];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
}
#pragma mark - Initializers
- (void)initializeBackground {
self.imageView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
self.backgroundView = [UIView new];
self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha];
[self.view addSubview:self.backgroundView];
[self.backgroundView autoPinEdgesToSuperviewEdges];
}
- (void)initializeContentViewAndFooterBar {
self.contentView = [UIView new];
[self.backgroundView addSubview:self.contentView];
[self.contentView autoPinWidthToSuperview];
[self.contentView autoPinToTopLayoutGuideOfViewController:self withInset:0];
self.footerBar = [UIToolbar new];
_footerBar.barTintColor = [UIColor ows_signalBrandBlueColor];
[self.footerBar setItems:@[
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction
target:self
action:@selector(shareWasPressed:)],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil],
]
animated:NO];
[self.backgroundView addSubview:self.footerBar];
[self.footerBar autoPinWidthToSuperview];
[self.footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0];
[self.footerBar autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.contentView];
}
- (void)shareWasPressed:(id)sender {
DDLogInfo(@"%@: sharing image.", self.logTag);
[AttachmentSharing showShareUIForURL:self.attachmentUrl];
}
- (void)initializeScrollView {
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
self.scrollView.delegate = self;
self.scrollView.zoomScale = 1.0f;
self.scrollView.maximumZoomScale = kMaxZoomScale;
self.scrollView.scrollEnabled = NO;
[self.contentView addSubview:self.scrollView];
}
- (void)initializeImageView {
if (self.isAnimated) {
if ([self.fileData ows_isValidImage]) {
YYImage *animatedGif = [YYImage imageWithData:self.fileData];
YYAnimatedImageView *imageView = [[YYAnimatedImageView alloc] init];
imageView.image = animatedGif;
imageView.frame = self.originRect;
imageView.contentMode = UIViewContentModeScaleAspectFill;
imageView.clipsToBounds = YES;
self.imageView = imageView;
} else {
self.imageView = [[UIImageView alloc] initWithFrame:self.originRect];
}
} else {
// Present the static image using standard UIImageView
self.imageView = [[UIImageView alloc] initWithFrame:self.originRect];
self.imageView.contentMode = UIViewContentModeScaleAspectFill;
self.imageView.userInteractionEnabled = YES;
self.imageView.clipsToBounds = YES;
self.imageView.layer.allowsEdgeAntialiasing = YES;
// Use trilinear filters for better scaling quality at
// some performance cost.
self.imageView.layer.minificationFilter = kCAFilterTrilinear;
self.imageView.layer.magnificationFilter = kCAFilterTrilinear;
}
[self.scrollView addSubview:self.imageView];
}
- (void)populateImageView:(UIImage *)image {
if (image && !self.isAnimated) {
self.imageView.image = image;
}
}
- (void)initializeGestureRecognizers {
UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(imageDismissGesture:)];
singleTap.delegate = self;
[self.view addGestureRecognizer:singleTap];
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(imageDismissGesture:)];
doubleTap.numberOfTapsRequired = 2;
doubleTap.delegate = self;
[self.view addGestureRecognizer:doubleTap];
// UISwipeGestureRecognizer supposedly supports multiple directions,
// but in practice it works better if you use a separate GR for each
// direction.
for (NSNumber *direction in @[
@(UISwipeGestureRecognizerDirectionRight),
@(UISwipeGestureRecognizerDirectionLeft),
@(UISwipeGestureRecognizerDirectionUp),
@(UISwipeGestureRecognizerDirectionDown),
]) {
UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(imageDismissGesture:)];
swipe.direction = (UISwipeGestureRecognizerDirection) direction.integerValue;
swipe.delegate = self;
[self.view addGestureRecognizer:swipe];
}
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(longPressGesture:)];
longPress.delegate = self;
[self.view addGestureRecognizer:longPress];
}
#pragma mark - Gesture Recognizers
- (void)imageDismissGesture:(UIGestureRecognizer *)sender {
if (sender.state == UIGestureRecognizerStateRecognized) {
[self dismiss];
}
}
- (void)longPressGesture:(UIGestureRecognizer *)sender {
// We "eagerly" respond when the long press begins, not when it ends.
if (sender.state == UIGestureRecognizerStateBegan) {
if (!self.viewItem) {
return;
}
[self.view becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
NSArray *menuItems = self.viewItem.menuControllerItems;
[UIMenuController sharedMenuController].menuItems = menuItems;
CGPoint location = [sender locationInView:self.view];
CGRect targetRect = CGRectMake(location.x,
location.y,
1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect
inView:self.view];
[[UIMenuController sharedMenuController] setMenuVisible:YES
animated:YES];
}
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (action == self.viewItem.metadataActionSelector) {
return NO;
}
return [self.viewItem canPerformAction:action];
}
- (void)copyAction:(nullable id)sender
{
[self.viewItem copyAction];
}
- (void)shareAction:(nullable id)sender
{
[self.viewItem shareAction];
}
- (void)saveAction:(nullable id)sender
{
[self.viewItem saveAction];
}
- (void)deleteAction:(nullable id)sender
{
[self.viewItem deleteAction];
[self dismiss];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
#pragma mark - Presentation
- (void)presentFromViewController:(UIViewController *)viewController {
_isPresenting = YES;
self.view.userInteractionEnabled = NO;
[self.view addSubview:self.imageView];
self.modalPresentationStyle = UIModalPresentationOverCurrentContext;
self.view.alpha = 0;
[viewController
presentViewController:self
animated:NO
completion:^{
UIWindow *window = [UIApplication sharedApplication].keyWindow;
// During the presentation animation, we want to seamlessly animate the image
// from its location in the conversation view. To do so, we need a
// consistent coordinate system, so we pass the `originRect` in the
// coordinate system of the window.
self.imageView.frame = [self.view convertRect:self.originRect
fromView:window];
[UIView animateWithDuration:0.25f
delay:0
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
animations:^() {
self.view.alpha = 1.0f;
// During the presentation animation, we want to seamlessly animate the image
// to its resting location in this view. We use `resizedFrameForImageView`
// to determine its size "at rest" in the content view, and then convert
// from the content view's coordinate system to the root view coordinate
// system because the image view is temporarily hosted by the root view during
// the presentation animation.
self.imageView.frame = [self resizedFrameForImageView:self.image.size];
self.imageView.center = [self.contentView convertPoint:self.contentView.center
fromView:self.contentView];
}
completion:^(BOOL completed) {
self.scrollView.frame = self.contentView.bounds;
[self.scrollView addSubview:self.imageView];
[self updateLayouts];
self.view.userInteractionEnabled = YES;
_isPresenting = NO;
}];
[UIUtil modalCompletionBlock]();
}];
}
- (void)dismiss {
self.view.userInteractionEnabled = NO;
[UIView animateWithDuration:0.25f
delay:0
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveLinear
animations:^() {
self.backgroundView.backgroundColor = [UIColor clearColor];
self.scrollView.alpha = 0;
self.view.alpha = 0;
}
completion:^(BOOL completed) {
[self.presentingViewController dismissViewControllerAnimated:NO completion:nil];
}];
}
#pragma mark - Update Layout
- (void)viewDidLayoutSubviews {
[self updateLayouts];
}
- (void)updateLayouts {
if (_isPresenting) {
return;
}
self.scrollView.frame = self.contentView.bounds;
self.imageView.frame = [self resizedFrameForImageView:self.image.size];
self.scrollView.contentSize = self.imageView.frame.size;
self.scrollView.contentInset = [self contentInsetForScrollView:self.scrollView.zoomScale];
}
#pragma mark - Resizing
- (CGRect)resizedFrameForImageView:(CGSize)imageSize {
CGRect frame = self.contentView.bounds;
CGSize screenSize =
CGSizeMake(frame.size.width * self.scrollView.zoomScale, frame.size.height * self.scrollView.zoomScale);
CGSize targetSize = screenSize;
if ([self isImagePortrait]) {
if ([self getAspectRatioForCGSize:screenSize] < [self getAspectRatioForCGSize:imageSize]) {
targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize];
} else {
targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize];
}
} else {
if ([self getAspectRatioForCGSize:screenSize] > [self getAspectRatioForCGSize:imageSize]) {
targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize];
} else {
targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize];
}
}
frame.size = targetSize;
frame.origin = CGPointMake(0, 0);
return frame;
}
- (UIEdgeInsets)contentInsetForScrollView:(CGFloat)targetZoomScale {
UIEdgeInsets inset = UIEdgeInsetsZero;
CGSize boundsSize = self.scrollView.bounds.size;
CGSize contentSize = self.image.size;
CGSize minSize;
if ([self isImagePortrait]) {
if ([self getAspectRatioForCGSize:boundsSize] < [self getAspectRatioForCGSize:contentSize]) {
minSize.height = boundsSize.height;
minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize];
} else {
minSize.width = boundsSize.width;
minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize];
}
} else {
if ([self getAspectRatioForCGSize:boundsSize] > [self getAspectRatioForCGSize:contentSize]) {
minSize.width = boundsSize.width;
minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize];
} else {
minSize.height = boundsSize.height;
minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize];
}
}
CGSize finalSize = self.view.bounds.size;
minSize.width *= targetZoomScale;
minSize.height *= targetZoomScale;
if (minSize.height > finalSize.height && minSize.width > finalSize.width) {
inset = UIEdgeInsetsZero;
} else {
CGFloat dy = boundsSize.height - minSize.height;
CGFloat dx = boundsSize.width - minSize.width;
dy = (dy > 0) ? dy : 0;
dx = (dx > 0) ? dx : 0;
inset.top = dy / 2.0f;
inset.bottom = dy / 2.0f;
inset.left = dx / 2.0f;
inset.right = dx / 2.0f;
}
return inset;
}
#pragma mark - UIScrollViewDelegate
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.imageView;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
scrollView.contentInset = [self contentInsetForScrollView:scrollView.zoomScale];
if (self.scrollView.scrollEnabled == NO) {
self.scrollView.scrollEnabled = YES;
}
}
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(nullable UIView *)view atScale:(CGFloat)scale
{
self.scrollView.scrollEnabled = (scale > 1);
self.scrollView.contentInset = [self contentInsetForScrollView:scale];
}
#pragma mark - Utility
- (BOOL)isImagePortrait {
return ([self getAspectRatioForCGSize:self.image.size] > 1.0f);
}
- (CGFloat)getAspectRatioForCGSize:(CGSize)size {
return size.height / size.width;
}
#pragma mark - Saving images to Camera Roll
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo {
if (error) {
DDLogWarn(@"There was a problem saving <%@> to camera roll from %s ",
error.localizedDescription,
__PRETTY_FUNCTION__);
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSViewController.h"
@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN
@class SignalAttachment;
@class TSAttachmentStream;
@interface FullImageViewController : OWSViewController
@interface MediaDetailViewController : OWSViewController
// If viewItem is non-null, long press will show a menu controller.
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream

View File

@ -0,0 +1,917 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "MediaDetailViewController.h"
#import "AttachmentSharing.h"
#import "ConversationViewController.h"
#import "ConversationViewItem.h"
#import "Signal-Swift.h"
#import "TSAttachmentStream.h"
#import "TSInteraction.h"
#import "UIColor+OWS.h"
#import "UIUtil.h"
#import "UIView+OWS.h"
#import <AVKit/AVKit.h>
#import <MediaPlayer/MPMoviePlayerViewController.h>
#import <MediaPlayer/MediaPlayer.h>
#import <SignalServiceKit/NSData+Image.h>
#import <YYImage/YYImage.h>
NS_ASSUME_NONNULL_BEGIN
// In order to use UIMenuController, the view from which it is
// presented must have certain custom behaviors.
@interface AttachmentMenuView : UIView
@end
#pragma mark -
@implementation AttachmentMenuView
- (BOOL)canBecomeFirstResponder {
return YES;
}
// We only use custom actions in UIMenuController.
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return NO;
}
@end
#pragma mark -
@interface MediaDetailViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate, PlayerProgressBarDelegate>
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIView *mediaView;
@property (nonatomic) UIButton *shareButton;
@property (nonatomic) CGRect originRect;
@property (nonatomic) NSData *fileData;
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, nullable) SignalAttachment *attachment;
@property (nonatomic, nullable) ConversationViewItem *viewItem;
@property (nonatomic) UIToolbar *footerBar;
@property (nonatomic) BOOL areToolbarsHidden;
@property (nonatomic, nullable) AVPlayer *videoPlayer;
@property (nonatomic, nullable) UIButton *playVideoButton;
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *imageViewConstraints;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
@end
@implementation MediaDetailViewController
- (instancetype)initWithAttachmentStream:(TSAttachmentStream *)attachmentStream
fromRect:(CGRect)rect
viewItem:(ConversationViewItem *_Nullable)viewItem
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachmentStream = attachmentStream;
self.originRect = rect;
self.viewItem = viewItem;
}
return self;
}
- (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect
{
self = [super initWithNibName:nil bundle:nil];
if (self) {
self.attachment = attachment;
self.originRect = rect;
}
return self;
}
- (NSURL *_Nullable)attachmentUrl
{
if (self.attachmentStream) {
return self.attachmentStream.mediaURL;
} else if (self.attachment) {
return self.attachment.dataUrl;
} else {
return nil;
}
}
- (NSData *)fileData
{
if (!_fileData) {
NSURL *_Nullable url = self.attachmentUrl;
if (url) {
_fileData = [NSData dataWithContentsOfURL:url];
}
}
return _fileData;
}
- (UIImage *)image {
if (self.attachmentStream) {
return self.attachmentStream.image;
} else if (self.attachment) {
if (self.isVideo) {
return self.attachment.videoPreview;
} else {
return self.attachment.image;
}
} else {
return nil;
}
}
- (BOOL)isAnimated
{
if (self.attachmentStream) {
return self.attachmentStream.isAnimated;
} else if (self.attachment) {
return self.attachment.isAnimatedImage;
} else {
return NO;
}
}
- (BOOL)isVideo
{
if (self.attachmentStream) {
return self.attachmentStream.isVideo;
} else if (self.attachment) {
return self.attachment.isVideo;
} else {
return NO;
}
}
- (void)loadView
{
self.view = [AttachmentMenuView new];
self.view.backgroundColor = [UIColor clearColor];
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self createContents];
[self initializeGestureRecognizers];
// Even though bars are opaque, we want content to be layed out behind them.
// The bars might obscure part of the content, but they can easily be hidden by tapping
// The alternative would be that content would shift when the navbars hide.
self.extendedLayoutIncludesOpaqueBars = YES;
// TODO better title.
self.title = @"Attachment";
self.navigationItem.leftBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop
target:self
action:@selector(didTapDismissButton:)];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
}
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self updateMinZoomScale];
[self centerImageViewConstraints];
}
- (void)updateMinZoomScale
{
CGSize viewSize = self.scrollView.bounds.size;
UIImage *image = self.image;
OWSAssert(image);
if (image.size.width == 0 || image.size.height == 0) {
OWSFail(@"%@ Invalid image dimensions. %@", self.logTag, NSStringFromCGSize(image.size));
return;
}
CGFloat scaleWidth = viewSize.width / image.size.width;
CGFloat scaleHeight = viewSize.height / image.size.height;
CGFloat minScale = MIN(scaleWidth, scaleHeight);
if (minScale != self.scrollView.minimumZoomScale) {
self.scrollView.minimumZoomScale = minScale;
self.scrollView.maximumZoomScale = minScale * 8;
self.scrollView.zoomScale = minScale;
}
}
#pragma mark - Initializers
- (void)createContents
{
CGFloat kFooterHeight = 44;
UIScrollView *scrollView = [UIScrollView new];
[self.view addSubview:scrollView];
self.scrollView = scrollView;
scrollView.delegate = self;
scrollView.showsVerticalScrollIndicator = NO;
scrollView.showsHorizontalScrollIndicator = NO;
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
self.automaticallyAdjustsScrollViewInsets = NO;
[scrollView autoPinToSuperviewEdges];
if (self.isAnimated) {
if ([self.fileData ows_isValidImage]) {
YYImage *animatedGif = [YYImage imageWithData:self.fileData];
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
animatedView.image = animatedGif;
self.mediaView = animatedView;
} else {
self.mediaView = [UIImageView new];
}
} else if (self.isVideo) {
self.mediaView = [self buildVideoPlayerView];
} else {
// Present the static image using standard UIImageView
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
self.mediaView = imageView;
}
OWSAssert(self.mediaView);
[scrollView addSubview:self.mediaView];
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
self.mediaView.userInteractionEnabled = YES;
self.mediaView.clipsToBounds = YES;
self.mediaView.layer.allowsEdgeAntialiasing = YES;
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
// Use trilinear filters for better scaling quality at
// some performance cost.
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
[self applyInitialImageViewConstraints];
if (self.isVideo) {
if (@available(iOS 9, *)) {
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
videoProgressBar.delegate = self;
videoProgressBar.player = self.videoPlayer;
self.videoProgressBar = videoProgressBar;
[self.view addSubview:videoProgressBar];
[videoProgressBar autoPinWidthToSuperview];
[videoProgressBar autoPinToTopLayoutGuideOfViewController:self withInset:0];
CGFloat kVideoProgressBarHeight = 44;
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
}
UIButton *playVideoButton = [UIButton new];
self.playVideoButton = playVideoButton;
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
UIImage *playImage = [UIImage imageNamed:@"play_button"];
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
[self.view addSubview:playVideoButton];
CGFloat playVideoButtonWidth = ScaleFromIPhone5(70);
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
[playVideoButton autoCenterInSuperview];
}
// Don't show footer bar after tapping approval-view
if (self.viewItem) {
UIToolbar *footerBar = [UIToolbar new];
_footerBar = footerBar;
footerBar.barTintColor = [UIColor ows_signalBrandBlueColor];
self.videoPlayBarButton =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemPlay
target:self
action:@selector(didPressPlayBarButton:)];
self.videoPauseBarButton =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemPause
target:self
action:@selector(didPressPauseBarButton:)];
[self updateFooterBarButtonItemsWithIsPlayingVideo:YES];
[self.view addSubview:footerBar];
[footerBar autoPinWidthToSuperview];
[footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0];
[footerBar autoSetDimension:ALDimensionHeight toSize:kFooterHeight];
}
}
- (void)updateFooterBarButtonItemsWithIsPlayingVideo:(BOOL)isPlayingVideo
{
OWSAssert(self.footerBar);
NSMutableArray<UIBarButtonItem *> *toolbarItems = [NSMutableArray new];
[toolbarItems addObjectsFromArray:@[
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction
target:self
action:@selector(didPressShare:)],
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil],
]];
if (self.isVideo) {
// bar button video controls only work on iOS9+
if (@available(iOS 9.0, *)) {
UIBarButtonItem *playerButton = isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton;
[toolbarItems addObjectsFromArray:@[
playerButton,
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
target:nil
action:nil],
]];
}
}
[toolbarItems addObject:[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemTrash
target:self
action:@selector(didPressDelete:)]];
[self.footerBar setItems:toolbarItems animated:NO];
}
- (void)applyInitialImageViewConstraints
{
if (self.imageViewConstraints.count > 0) {
[NSLayoutConstraint deactivateConstraints:self.imageViewConstraints];
}
CGRect convertedRect =
[self.mediaView.superview convertRect:self.originRect fromView:[UIApplication sharedApplication].keyWindow];
NSMutableArray<NSLayoutConstraint *> *imageViewConstraints = [NSMutableArray new];
self.imageViewConstraints = imageViewConstraints;
[imageViewConstraints addObjectsFromArray:[self.mediaView autoSetDimensionsToSize:convertedRect.size]];
[imageViewConstraints addObjectsFromArray:@[
[self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y],
[self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x]
]];
}
- (void)applyFinalImageViewConstraints
{
if (self.imageViewConstraints.count > 0) {
[NSLayoutConstraint deactivateConstraints:self.imageViewConstraints];
}
NSMutableArray<NSLayoutConstraint *> *imageViewConstraints = [NSMutableArray new];
self.imageViewConstraints = imageViewConstraints;
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
[imageViewConstraints addObjectsFromArray:@[
self.mediaViewTopConstraint,
self.mediaViewTrailingConstraint,
self.mediaViewBottomConstraint,
self.mediaViewLeadingConstraint
]];
}
- (UIView *)buildVideoPlayerView
{
NSFileManager *fileManager = [NSFileManager defaultManager];
if (![fileManager fileExistsAtPath:[self.attachmentUrl path]]) {
OWSFail(@"%@ Missing video file: %@", self.logTag, self.attachmentStream.mediaURL);
}
if (@available(iOS 9.0, *)) {
AVPlayer *player = [[AVPlayer alloc] initWithURL:self.attachmentUrl];
[player seekToTime:kCMTimeZero];
self.videoPlayer = player;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(playerItemDidPlayToCompletion:)
name:AVPlayerItemDidPlayToEndTimeNotification
object:player.currentItem];
VideoPlayerView *playerView = [VideoPlayerView new];
playerView.player = player;
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
forConstraints:^{
[playerView autoSetDimensionsToSize:self.image.size];
}];
return playerView;
} else {
return [[UIImageView alloc] initWithImage:self.image];
}
}
- (void)setAreToolbarsHidden:(BOOL)areToolbarsHidden
{
if (_areToolbarsHidden == areToolbarsHidden) {
return;
}
_areToolbarsHidden = areToolbarsHidden;
// Hiding the status bar affects the positioing of the navbar. We don't want to show that in an animation, it's
// better to just have everythign "flit" in/out.
[[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden withAnimation:UIStatusBarAnimationNone];
[self.navigationController setNavigationBarHidden:areToolbarsHidden animated:NO];
self.videoProgressBar.hidden = areToolbarsHidden;
[UIView animateWithDuration:0.1
animations:^(void) {
self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor;
self.footerBar.alpha = areToolbarsHidden ? 0 : 1;
}];
}
- (void)initializeGestureRecognizers
{
UITapGestureRecognizer *doubleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
doubleTap.numberOfTapsRequired = 2;
[self.view addGestureRecognizer:doubleTap];
UITapGestureRecognizer *singleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapImage:)];
[singleTap requireGestureRecognizerToFail:doubleTap];
[self.view addGestureRecognizer:singleTap];
// UISwipeGestureRecognizer supposedly supports multiple directions,
// but in practice it works better if you use a separate GR for each
// direction.
for (NSNumber *direction in @[
@(UISwipeGestureRecognizerDirectionRight),
@(UISwipeGestureRecognizerDirectionLeft),
@(UISwipeGestureRecognizerDirectionUp),
@(UISwipeGestureRecognizerDirectionDown),
]) {
UISwipeGestureRecognizer *swipe =
[[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(didSwipeImage:)];
swipe.direction = (UISwipeGestureRecognizerDirection) direction.integerValue;
swipe.delegate = self;
[self.view addGestureRecognizer:swipe];
}
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(longPressGesture:)];
longPress.delegate = self;
[self.view addGestureRecognizer:longPress];
}
#pragma mark - Gesture Recognizers
- (void)didTapDismissButton:(id)sender
{
[self dismissSelfAnimated:YES completion:nil];
}
- (void)didTapImage:(id)sender
{
DDLogVerbose(@"%@ did tap image.", self.logTag);
self.areToolbarsHidden = !self.areToolbarsHidden;
}
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
{
DDLogVerbose(@"%@ did double tap image.", self.logTag);
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
CGFloat kDoubleTapZoomScale = 2;
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
// center zoom rect around tapLocation
CGPoint tapLocation = [gesture locationInView:self.scrollView];
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
[self.scrollView zoomToRect:translatedRect animated:YES];
} else {
// If already zoomed in at all, zoom out all the way.
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES];
}
}
- (void)didSwipeImage:(UIGestureRecognizer *)sender
{
// Ignore if image is zoomed in at all.
// e.g. otherwise, for example, if the image is horizontally larger than the scroll
// view, but fits vertically, swiping left/right will scroll the image, but swiping up/down
// would dismiss the image. That would not be intuitive.
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
return;
}
[self dismissSelfAnimated:YES completion:nil];
}
- (void)longPressGesture:(UIGestureRecognizer *)sender {
// We "eagerly" respond when the long press begins, not when it ends.
if (sender.state == UIGestureRecognizerStateBegan) {
if (!self.viewItem) {
return;
}
[self.view becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO
animated:NO];
}
NSArray *menuItems = self.viewItem.menuControllerItems;
[UIMenuController sharedMenuController].menuItems = menuItems;
CGPoint location = [sender locationInView:self.view];
CGRect targetRect = CGRectMake(location.x,
location.y,
1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect
inView:self.view];
[[UIMenuController sharedMenuController] setMenuVisible:YES
animated:YES];
}
}
- (void)didPressShare:(id)sender
{
DDLogInfo(@"%@: didPressShare", self.logTag);
if (!self.viewItem) {
OWSFail(@"share should only be available when a viewItem is present");
return;
}
[self.viewItem shareAction];
}
- (void)didPressDelete:(id)sender
{
DDLogInfo(@"%@: didPressDelete", self.logTag);
if (!self.viewItem) {
OWSFail(@"delete should only be available when a viewItem is present");
return;
}
UIAlertController *actionSheet =
[UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
[actionSheet
addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_DELETE_TITLE", nil)
style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *action) {
OWSAssert([self.presentingViewController
isKindOfClass:[UINavigationController class]]);
UINavigationController *navController
= (UINavigationController *)self.presentingViewController;
if ([navController.topViewController
isKindOfClass:[ConversationViewController class]]) {
[self dismissSelfAnimated:YES
completion:^{
[self.viewItem deleteAction];
}];
} else if ([navController.topViewController
isKindOfClass:[MessageDetailViewController class]]) {
[self dismissSelfAnimated:NO
completion:^{
[self.viewItem deleteAction];
}];
[navController popViewControllerAnimated:YES];
} else {
OWSFail(@"Unexpected presentation context.");
[self dismissSelfAnimated:YES
completion:^{
[self.viewItem deleteAction];
}];
}
}]];
[actionSheet addAction:[OWSAlerts cancelAction]];
[self presentViewController:actionSheet animated:YES completion:nil];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (self.viewItem == nil) {
return NO;
}
if (action == self.viewItem.metadataActionSelector) {
return NO;
}
return [self.viewItem canPerformAction:action];
}
- (void)copyAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"copy should only be available when a viewItem is present");
return;
}
[self.viewItem copyAction];
}
- (void)shareAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"share should only be available when a viewItem is present");
return;
}
[self didPressShare:sender];
}
- (void)saveAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"save should only be available when a viewItem is present");
return;
}
[self.viewItem saveAction];
}
- (void)deleteAction:(nullable id)sender
{
if (!self.viewItem) {
OWSFail(@"delete should only be available when a viewItem is present");
return;
}
[self didPressDelete:sender];
}
- (void)didPressPlayBarButton:(id)sender
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self playVideo];
}
- (void)didPressPauseBarButton:(id)sender
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self pauseVideo];
}
#pragma mark - Presentation
- (void)presentFromViewController:(UIViewController *)viewController
{
UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:self];
// UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually
// animate in our view, over the existing context, similar to a cross disolve, but allowing us to have
// more fine grained control
navController.modalPresentationStyle = UIModalPresentationCustom;
navController.navigationBar.barTintColor = UIColor.ows_materialBlueColor;
navController.navigationBar.translucent = NO;
navController.navigationBar.opaque = YES;
self.view.userInteractionEnabled = NO;
self.view.alpha = 0.0;
[viewController presentViewController:navController
animated:NO
completion:^{
// 1. Fade in the entire view.
[UIView animateWithDuration:0.1
animations:^{
self.view.alpha = 1.0;
}];
// Make sure imageView is layed out before we update it's frame in the next
// animation.
[self.mediaView.superview layoutIfNeeded];
// 2. Animate imageView from it's initial position, which should match where it was
// in the presenting view to it's final position, front and center in this view. This
// animation intentionally overlaps the previous
[UIView animateWithDuration:0.2
delay:0.08
options:UIViewAnimationOptionCurveEaseOut
animations:^(void) {
[self applyFinalImageViewConstraints];
[self.mediaView.superview layoutIfNeeded];
// We must lay out *before* we centerImageViewConstraints
// because it uses the imageView.frame to build the contstraints
// that will center the imageView, and then once again
// to ensure that the centered constraints are applied.
[self centerImageViewConstraints];
[self.mediaView.superview layoutIfNeeded];
self.view.backgroundColor = UIColor.whiteColor;
}
completion:^(BOOL finished) {
self.view.userInteractionEnabled = YES;
if (self.isVideo) {
[self playVideo];
}
}];
}];
}
- (void)dismissSelfAnimated:(BOOL)isAnimated completion:(void (^_Nullable)(void))completion
{
self.view.userInteractionEnabled = NO;
[UIApplication sharedApplication].statusBarHidden = NO;
OWSAssert(self.mediaView.superview);
[self.mediaView.superview layoutIfNeeded];
// Move the image view pack to it's initial position, i.e. where
// it sits on the screen in the conversation view.
[self applyInitialImageViewConstraints];
if (isAnimated) {
[UIView animateWithDuration:0.2
delay:0.0
options:UIViewAnimationOptionCurveEaseInOut
animations:^(void) {
[self.mediaView.superview layoutIfNeeded];
// In case user has hidden bars, which changes background to black.
self.view.backgroundColor = UIColor.whiteColor;
// fade out content and toolbars
self.navigationController.view.alpha = 0.0;
}
completion:^(BOOL finished) {
[self.presentingViewController dismissViewControllerAnimated:NO completion:completion];
}];
} else {
[self.presentingViewController dismissViewControllerAnimated:NO completion:completion];
}
}
#pragma mark - UIScrollViewDelegate
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.mediaView;
}
- (void)centerImageViewConstraints
{
OWSAssert(self.scrollView);
CGSize scrollViewSize = self.scrollView.bounds.size;
CGSize imageViewSize = self.mediaView.frame.size;
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
self.mediaViewTopConstraint.constant = yOffset;
self.mediaViewBottomConstraint.constant = yOffset;
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
self.mediaViewLeadingConstraint.constant = xOffset;
self.mediaViewTrailingConstraint.constant = xOffset;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
[self centerImageViewConstraints];
[self.view layoutIfNeeded];
}
#pragma mark - Video Playback
- (void)playVideo
{
if (@available(iOS 9, *)) {
OWSAssert(self.videoPlayer);
AVPlayer *player = self.videoPlayer;
[self updateFooterBarButtonItemsWithIsPlayingVideo:YES];
self.playVideoButton.hidden = YES;
self.areToolbarsHidden = YES;
OWSAssert(player.currentItem);
AVPlayerItem *item = player.currentItem;
if (CMTIME_COMPARE_INLINE(item.currentTime, ==, item.duration)) {
// Rewind for repeated plays
[player seekToTime:kCMTimeZero];
}
[player play];
} else {
[self legacyPlayVideo];
return;
}
}
- (void)pauseVideo
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
[self.videoPlayer pause];
}
- (void)playerItemDidPlayToCompletion:(NSNotification *)notification
{
OWSAssert(self.isVideo);
OWSAssert(self.videoPlayer);
DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__);
self.areToolbarsHidden = NO;
self.playVideoButton.hidden = NO;
[self updateFooterBarButtonItemsWithIsPlayingVideo:NO];
}
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
{
OWSAssert(self.videoPlayer);
[self.videoPlayer pause];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
{
OWSAssert(self.videoPlayer);
[self.videoPlayer seekToTime:time];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
didFinishScrubbingAtTime:(CMTime)time
shouldResumePlayback:(BOOL)shouldResumePlayback
{
OWSAssert(self.videoPlayer);
[self.videoPlayer seekToTime:time];
if (shouldResumePlayback) {
[self.videoPlayer play];
}
}
#pragma mark iOS8 Video Playback
// AVPlayer was introduced in iOS9, so on iOS8 we fall back to MPMoviePlayer
// This causes an unforutnate "double present" since we present the full screen view and then the MPMovie view over top.
// And similarly a double dismiss.
- (void)legacyPlayVideo
{
if (@available(iOS 9.0, *)) {
OWSFail(@"legacy video is for iOS8 only");
}
MPMoviePlayerViewController *vc = [[MPMoviePlayerViewController alloc] initWithContentURL:self.attachmentUrl];
[self presentViewController:vc animated:YES completion:nil];
}
#pragma mark - Saving images to Camera Roll
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo {
if (error) {
DDLogWarn(@"There was a problem saving <%@> to camera roll from %s ",
error.localizedDescription,
__PRETTY_FUNCTION__);
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -19,6 +19,11 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
let mode: MediaMessageViewMode
// If this is for a persisted interaction, viewItem and attachmentStream will not be nil
// If this is for an attachmet draft, before sending, viewItem and attachmentStream will be nil
var viewItem: ConversationViewItem?
var attachmentStream: TSAttachmentStream?
let attachment: SignalAttachment
var videoPlayer: MPMoviePlayerController?
@ -45,10 +50,16 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
fatalError("\(#function) is unimplemented.")
}
required init(attachment: SignalAttachment, mode: MediaMessageViewMode) {
convenience init(attachment: SignalAttachment, mode: MediaMessageViewMode) {
self.init(attachment: attachment, mode: mode, viewItem: nil, attachmentStream: nil)
}
required init(attachment: SignalAttachment, mode: MediaMessageViewMode, viewItem: ConversationViewItem?, attachmentStream: TSAttachmentStream?) {
assert(!attachment.hasError)
self.mode = mode
self.attachment = attachment
self.mode = mode
self.viewItem = viewItem
self.attachmentStream = attachmentStream
super.init(frame: CGRect.zero)
createViews()
@ -401,13 +412,7 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
guard let fromView = sender.view else {
return
}
guard let fromViewController = fromViewController() else {
return
}
let window = UIApplication.shared.keyWindow
let convertedRect = fromView.convert(fromView.bounds, to:window)
let viewController = FullImageViewController(attachment:attachment, from:convertedRect)
viewController.present(from:fromViewController)
showMediaDetailViewController(fromView: fromView)
}
private func fromViewController() -> UIViewController? {
@ -429,44 +434,28 @@ class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate {
guard sender.state == .recognized else {
return
}
guard let dataUrl = attachment.dataUrl else {
guard let fromView = sender.view else {
return
}
guard let videoPlayer = MPMoviePlayerController(contentURL: dataUrl) else {
showMediaDetailViewController(fromView: fromView)
}
func showMediaDetailViewController(fromView: UIView) {
guard let fromViewController = fromViewController() else {
return
}
videoPlayer.prepareToPlay()
let window = UIApplication.shared.keyWindow
let convertedRect = fromView.convert(fromView.bounds, to:window)
NotificationCenter.default.addObserver(forName: .MPMoviePlayerWillExitFullscreen, object: nil, queue: nil) { [weak self] _ in
self?.moviePlayerWillExitFullscreen()
}
NotificationCenter.default.addObserver(forName: .MPMoviePlayerDidExitFullscreen, object: nil, queue: nil) { [weak self] _ in
self?.moviePlayerDidExitFullscreen()
}
let viewController: MediaDetailViewController = {
if let viewItem = self.viewItem, let attachmentStream = self.attachmentStream {
return MediaDetailViewController(attachmentStream: attachmentStream, from: convertedRect, viewItem: viewItem)
} else {
// e.g. when MediaMessageView does not belong to a persisted interaction, e.g. approval view.
return MediaDetailViewController(attachment: attachment, from:convertedRect)
}
}()
videoPlayer.controlStyle = .default
videoPlayer.shouldAutoplay = true
self.addSubview(videoPlayer.view)
videoPlayer.view.frame = self.bounds
self.videoPlayer = videoPlayer
videoPlayer.view.autoPinToSuperviewEdges()
ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(true)
videoPlayer.setFullscreen(true, animated:false)
}
private func moviePlayerWillExitFullscreen() {
clearVideoPlayer()
}
private func moviePlayerDidExitFullscreen() {
clearVideoPlayer()
}
private func clearVideoPlayer() {
videoPlayer?.stop()
videoPlayer?.view.removeFromSuperview()
videoPlayer = nil
ViewControllerUtils.setAudioIgnoresHardwareMuteSwitch(false)
viewController.present(from:fromViewController)
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -406,7 +406,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
}
guard let attachment = TSAttachment.fetch(uniqueId: attachmentId) else {
owsFail("Missing attachment")
Logger.warn("\(TAG) Missing attachment. Was it deleted?")
return rows
}
self.attachment = attachment
@ -433,7 +433,7 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate {
let contentType = attachment.contentType
if let dataUTI = MIMETypeUtil.utiType(forMIMEType: contentType) {
let attachment = SignalAttachment(dataSource: dataSource, dataUTI: dataUTI)
let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small)
let mediaMessageView = MediaMessageView(attachment: attachment, mode: .small, viewItem: viewItem, attachmentStream: attachmentStream)
mediaMessageView.backgroundColor = UIColor.white
self.mediaMessageView = mediaMessageView
rows.append(mediaMessageView)

View File

@ -0,0 +1,200 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@available(iOS 9.0, *)
@objc
public class VideoPlayerView: UIView {
var player: AVPlayer? {
get {
return playerLayer.player
}
set {
playerLayer.player = newValue
}
}
var playerLayer: AVPlayerLayer {
return layer as! AVPlayerLayer
}
// Override UIView property
override public static var layerClass: AnyClass {
return AVPlayerLayer.self
}
}
@available(iOS 9.0, *)
@objc
public protocol PlayerProgressBarDelegate {
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool)
}
@available(iOS 9.0, *)
@objc
public class PlayerProgressBar: UIView {
public let TAG = "[PlayerProgressBar]"
@objc
public weak var delegate: PlayerProgressBarDelegate?
private lazy var formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.minute, .second ]
formatter.zeroFormattingBehavior = [ .pad ]
return formatter
}()
// MARK: Subviews
private let positionLabel = UILabel()
private let remainingLabel = UILabel()
private let slider = UISlider()
private let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
weak private var progressObserver: AnyObject?
private let kPreferredTimeScale: CMTimeScale = 100
public var player: AVPlayer? {
didSet {
guard let item = player?.currentItem else {
owsFail("No player item")
return
}
slider.minimumValue = 0
let duration: CMTime = item.asset.duration
slider.maximumValue = Float(CMTimeGetSeconds(duration))
// OPTIMIZE We need a high frequency observer for smooth slider updates,
// but could use a much less frequent observer for label updates
progressObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: kPreferredTimeScale), queue: nil, using: { [weak self] (_) in
self?.updateState()
}) as AnyObject
updateState()
}
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public init(frame: CGRect) {
super.init(frame: frame)
// Background
backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
if !UIAccessibilityIsReduceTransparencyEnabled() {
addSubview(blurEffectView)
blurEffectView.autoPinToSuperviewEdges()
}
// Configure controls
let kLabelFont = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: UIFontWeightRegular)
positionLabel.font = kLabelFont
remainingLabel.font = kLabelFont
// We use a smaller thumb for the progress slider.
slider.setThumbImage(#imageLiteral(resourceName: "sliderProgressThumb"), for: .normal)
slider.maximumTrackTintColor = UIColor.ows_black()
slider.minimumTrackTintColor = UIColor.ows_black()
slider.addTarget(self, action: #selector(handleSliderTouchDown), for: .touchDown)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpInside)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpOutside)
slider.addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged)
// Layout Subviews
addSubview(positionLabel)
addSubview(remainingLabel)
addSubview(slider)
positionLabel.autoPinEdge(toSuperviewMargin: .leading)
positionLabel.autoVCenterInSuperview()
let kSliderMargin: CGFloat = 8
slider.autoPinEdge(.leading, to: .trailing, of: positionLabel, withOffset: kSliderMargin)
slider.autoVCenterInSuperview()
remainingLabel.autoPinEdge(.leading, to: .trailing, of: slider, withOffset: kSliderMargin)
remainingLabel.autoPinEdge(toSuperviewMargin: .trailing)
remainingLabel.autoVCenterInSuperview()
}
// MARK: Gesture handling
var wasPlayingWhenScrubbingStarted: Bool = false
@objc
private func handleSliderTouchDown(_ slider: UISlider) {
guard let player = self.player else {
owsFail("player was nil")
return
}
self.wasPlayingWhenScrubbingStarted = (player.rate != 0) && (player.error == nil)
self.delegate?.playerProgressBarDidStartScrubbing(self)
}
@objc
private func handleSliderTouchUp(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, didFinishScrubbingAtTime: sliderTime, shouldResumePlayback:wasPlayingWhenScrubbingStarted)
}
@objc
private func handleSliderValueChanged(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, scrubbedToTime: sliderTime)
}
// MARK: Render cycle
private func updateState() {
guard let player = player else {
owsFail("\(TAG) player isn't set.")
return
}
guard let item = player.currentItem else {
owsFail("\(TAG) player has no item.")
return
}
let position = player.currentTime()
let positionSeconds: Float64 = CMTimeGetSeconds(position)
positionLabel.text = formatter.string(from: positionSeconds)
let duration: CMTime = item.asset.duration
let remainingTime = duration - position
let remainingSeconds = CMTimeGetSeconds(remainingTime)
guard let remainingString = formatter.string(from: remainingSeconds) else {
owsFail("unable to format time remaining")
remainingLabel.text = "0:00"
return
}
// show remaining time as negative
remainingLabel.text = "-\(remainingString)"
slider.setValue(Float(positionSeconds), animated: false)
}
// MARK: Util
private func time(slider: UISlider) -> CMTime {
let seconds: Double = Double(slider.value)
return CMTime(seconds: seconds, preferredTimescale: kPreferredTimeScale)
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSOrphanedDataCleaner.h"