//
// C o p y r i g h t ( c ) 2 0 1 8 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import Foundation
import AVFoundation
import MediaPlayer
@objc
public protocol AttachmentApprovalViewControllerDelegate : class {
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didApproveAttachments attachments : [ SignalAttachment ] )
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didCancelAttachments attachments : [ SignalAttachment ] )
}
struct SignalAttachmentItem : Hashable {
let attachment : SignalAttachment
}
@objc
public class AttachmentApprovalViewController : UIPageViewController , UIPageViewControllerDataSource , UIPageViewControllerDelegate , CaptioningToolbarDelegate {
// MARK: P r o p e r t i e s
weak var approvalDelegate : AttachmentApprovalViewControllerDelegate ?
private ( set ) var captioningToolbar : CaptioningToolbar !
// MARK: I n i t i a l i z e r s
@ available ( * , unavailable , message : " use attachment: constructor instead. " )
required public init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
let kSpacingBetweenItems : CGFloat = 20
@objc
required public init ( attachments : [ SignalAttachment ] ) {
assert ( attachments . count > 0 )
self . attachmentItems = attachments . map { SignalAttachmentItem ( attachment : $0 ) }
super . init ( transitionStyle : . scroll ,
navigationOrientation : . horizontal ,
options : [ UIPageViewControllerOptionInterPageSpacingKey : kSpacingBetweenItems ] )
self . dataSource = self
self . delegate = self
}
@objc
public class func wrappedInNavController ( attachments : [ SignalAttachment ] , approvalDelegate : AttachmentApprovalViewControllerDelegate ) -> OWSNavigationController {
let vc = AttachmentApprovalViewController ( attachments : attachments )
vc . approvalDelegate = approvalDelegate
let navController = OWSNavigationController ( rootViewController : vc )
guard let navigationBar = navController . navigationBar as ? OWSNavigationBar else {
owsFailDebug ( " navigationBar was nil or unexpected class " )
return navController
}
navigationBar . makeClear ( )
return navController
}
// MARK: V i e w L i f e c y c l e
override public func viewDidLoad ( ) {
super . viewDidLoad ( )
self . view . backgroundColor = . black
disablePagingIfNecessary ( )
// B o t t o m T o o l b a r
let captioningToolbar = CaptioningToolbar ( )
captioningToolbar . captioningToolbarDelegate = self
self . captioningToolbar = captioningToolbar
// N a v i g a t i o n
self . navigationItem . title = nil
let cancelButton = UIBarButtonItem ( barButtonSystemItem : . stop , target : self , action : #selector ( cancelPressed ) )
cancelButton . tintColor = . white
self . navigationItem . leftBarButtonItem = cancelButton
guard let firstItem = attachmentItems . first else {
owsFailDebug ( " firstItem was unexpectedly nil " )
return
}
self . setCurrentItem ( firstItem , direction : . forward , animated : false )
}
override public func viewWillAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillAppear ( animated )
CurrentAppContext ( ) . setStatusBarHidden ( true , animated : animated )
}
override public func viewDidAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewDidAppear ( animated )
}
override public func viewWillDisappear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillDisappear ( animated )
// S i n c e t h i s V C i s b e i n g d i s m i s s e d , t h e " s h o w s t a t u s b a r " a n i m a t i o n w o u l d f e e l l i k e
// i t ' s o c c u r i n g o n t h e p r e s e n t i n g v i e w c o n t r o l l e r - i t ' s b e t t e r n o t t o a n i m a t e a t a l l .
CurrentAppContext ( ) . setStatusBarHidden ( false , animated : false )
}
override public var inputAccessoryView : UIView ? {
self . captioningToolbar . layoutIfNeeded ( )
return self . captioningToolbar
}
override public var canBecomeFirstResponder : Bool {
return true
}
// MARK: - V i e w H e l p e r s
var pagerScrollView : UIScrollView ?
// T h i s i s k i n d o f a h a c k . S i n c e w e d o n ' t h a v e f i r s t c l a s s a c c e s s t o t h e s u p e r v i e w ' s ` s c r o l l V i e w `
// w e t r a v e r s e t h e v i e w h i e r a r c h y u n t i l w e f i n d i t , t h e n d i s a b l e s c r o l l i n g i f t h e r e ' s o n l y o n e
// i t e m . T h i s a v o i d s a n u n p l e a s a n t " b o u n c e " w h i c h d o e s n ' t m a k e s e n s e i n t h e c o n t e x t o f a s i n g l e i t e m .
fileprivate func disablePagingIfNecessary ( ) {
for view in self . view . subviews {
if let pagerScrollView = view as ? UIScrollView {
self . pagerScrollView = pagerScrollView
break
}
}
guard let pagerScrollView = self . pagerScrollView else {
owsFailDebug ( " pagerScrollView was unexpectedly nil " )
return
}
pagerScrollView . isScrollEnabled = attachmentItems . count > 1
}
// MARK: U I P a g e V i e w C o n t r o l l e r D e l e g a t e
public func pageViewController ( _ pageViewController : UIPageViewController , willTransitionTo pendingViewControllers : [ UIViewController ] ) {
Logger . debug ( " " )
assert ( pendingViewControllers . count = = 1 )
pendingViewControllers . forEach { viewController in
guard let pendingPage = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return
}
// u s e c o m p a c t s c a l e w h e n k e y b o a r d i s p o p p e d .
let scale : AttachmentPrepViewController . AttachmentViewScale = self . isFirstResponder ? . fullsize : . compact
pendingPage . setAttachmentViewScale ( scale , animated : false )
}
}
public func pageViewController ( _ pageViewController : UIPageViewController , didFinishAnimating finished : Bool , previousViewControllers : [ UIViewController ] , transitionCompleted : Bool ) {
Logger . debug ( " " )
assert ( previousViewControllers . count = = 1 )
previousViewControllers . forEach { viewController in
guard let previousPage = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return
}
if transitionCompleted {
UIView . transition ( with : self . captioningToolbar ,
duration : 0.1 ,
options : . transitionCrossDissolve ,
animations : {
self . captioningToolbar . captionText = self . currentViewController . attachment . captionText
} ,
completion : nil )
previousPage . zoomOut ( animated : false )
}
}
}
// MARK: U I P a g e V i e w C o n t r o l l e r D a t a S o u r c e
public func pageViewController ( _ pageViewController : UIPageViewController , viewControllerBefore viewController : UIViewController ) -> UIViewController ? {
guard let currentViewController = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return nil
}
let currentItem = currentViewController . attachmentItem
guard let previousItem = attachmentItem ( before : currentItem ) else {
return nil
}
guard let previousPage : AttachmentPrepViewController = buildPage ( item : previousItem ) else {
return nil
}
return previousPage
}
public func pageViewController ( _ pageViewController : UIPageViewController , viewControllerAfter viewController : UIViewController ) -> UIViewController ? {
Logger . debug ( " " )
guard let currentViewController = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return nil
}
let currentItem = currentViewController . attachmentItem
guard let nextItem = attachmentItem ( after : currentItem ) else {
return nil
}
guard let nextPage : AttachmentPrepViewController = buildPage ( item : nextItem ) else {
return nil
}
return nextPage
}
public var currentViewController : AttachmentPrepViewController {
return viewControllers ! . first as ! AttachmentPrepViewController
}
var currentItem : SignalAttachmentItem ! {
get {
return currentViewController . attachmentItem
}
set {
setCurrentItem ( newValue , direction : . forward , animated : false )
}
}
private var cachedPages : [ SignalAttachmentItem : AttachmentPrepViewController ] = [ : ]
private func buildPage ( item : SignalAttachmentItem ) -> AttachmentPrepViewController ? {
if let cachedPage = cachedPages [ item ] {
Logger . debug ( " cache hit. " )
return cachedPage
}
Logger . debug ( " cache miss. " )
let viewController = AttachmentPrepViewController ( attachmentItem : item )
cachedPages [ item ] = viewController
return viewController
}
private func setCurrentItem ( _ item : SignalAttachmentItem , direction : UIPageViewControllerNavigationDirection , animated isAnimated : Bool ) {
guard let page = self . buildPage ( item : item ) else {
owsFailDebug ( " unexpetedly unable to build new page " )
return
}
self . setViewControllers ( [ page ] , direction : direction , animated : isAnimated , completion : nil )
// T O D O u p d a t e r a i l
}
let attachmentItems : [ SignalAttachmentItem ]
var attachments : [ SignalAttachment ] {
return attachmentItems . map { $0 . attachment }
}
func attachmentItem ( before currentItem : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let index : Int = attachmentItems . index ( before : currentIndex )
guard let previousItem = attachmentItems [ safe : index ] else {
// a l r e a d y a t f i r s t i t e m
return nil
}
return previousItem
}
func attachmentItem ( after currentItem : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let index : Int = attachmentItems . index ( after : currentIndex )
guard let nextItem = attachmentItems [ safe : index ] else {
// a l r e a d y a t l a s t i t e m
return nil
}
return nextItem
}
// MARK: - E v e n t H a n d l e r s
@objc func cancelPressed ( sender : UIButton ) {
self . approvalDelegate ? . attachmentApproval ( self , didCancelAttachments : attachments )
}
// MARK: C a p t i o n i n g T o o l b a r D e l e g a t e
var currentPageController : AttachmentPrepViewController {
return viewControllers ! . first as ! AttachmentPrepViewController
}
func captioningToolbarDidBeginEditing ( _ captioningToolbar : CaptioningToolbar ) {
currentPageController . setAttachmentViewScale ( . compact , animated : true )
}
func captioningToolbarDidEndEditing ( _ captioningToolbar : CaptioningToolbar ) {
currentPageController . setAttachmentViewScale ( . fullsize , animated : true )
}
func captioningToolbarDidTapSend ( _ captioningToolbar : CaptioningToolbar ) {
// T o o l b a r f l i c k e r s i n a n d o u t i f t h e r e a r e e r r o r s
// a n d r e m a i n s v i s i b l e m o m e n t a r i l y a f t e r s h a r e e x t e n s i o n i s d i s m i s s e d .
// I t ' s e a s i e s t t o j u s t h i d e i t a t t h i s p o i n t s i n c e w e ' r e d o n e w i t h i t .
currentViewController . shouldAllowAttachmentViewResizing = false
captioningToolbar . isUserInteractionEnabled = false
captioningToolbar . isHidden = true
approvalDelegate ? . attachmentApproval ( self , didApproveAttachments : attachments )
}
func captioningToolbar ( _ captioningToolbar : CaptioningToolbar , textViewDidChange textView : UITextView ) {
currentItem . attachment . captionText = textView . text
}
}
public class AttachmentPrepViewController : OWSViewController , PlayerProgressBarDelegate , OWSVideoPlayerDelegate {
// W e s o m e t i m e s s h r i n k t h e a t t a c h m e n t v i e w s o t h a t i t r e m a i n s s o m e w h a t v i s i b l e
// w h e n t h e k e y b o a r d i s p r e s e n t e d .
enum AttachmentViewScale {
case fullsize , compact
}
// MARK: P r o p e r t i e s
let attachmentItem : SignalAttachmentItem
var attachment : SignalAttachment {
return attachmentItem . attachment
}
private var videoPlayer : OWSVideoPlayer ?
private ( set ) var mediaMessageView : MediaMessageView !
private ( set ) var scrollView : UIScrollView !
private ( set ) var contentContainer : UIView !
private ( set ) var playVideoButton : UIView ?
// MARK: I n i t i a l i z e r s
init ( attachmentItem : SignalAttachmentItem ) {
self . attachmentItem = attachmentItem
super . init ( nibName : nil , bundle : nil )
assert ( ! attachment . hasError )
}
public required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK: V i e w L i f e c y c l e
override public func loadView ( ) {
self . view = UIView ( )
self . mediaMessageView = MediaMessageView ( attachment : attachment , mode : . attachmentApproval )
// A n y t h i n g t h a t s h o u l d b e s h r u n k w h e n u s e r p o p s k e y b o a r d l i v e s i n t h e c o n t e n t C o n t a i n e r .
let contentContainer = UIView ( )
self . contentContainer = contentContainer
view . addSubview ( contentContainer )
contentContainer . autoPinEdgesToSuperviewEdges ( )
// S c r o l l V i e w - u s e d t o z o o m / p a n o n i m a g e s a n d v i d e o
scrollView = UIScrollView ( )
contentContainer . addSubview ( scrollView )
scrollView . delegate = self
scrollView . showsHorizontalScrollIndicator = false
scrollView . showsVerticalScrollIndicator = false
// P a n n i n g s h o u l d s t o p p r e t t y s o o n a f t e r t h e u s e r s t o p s s c r o l l i n g
scrollView . decelerationRate = UIScrollViewDecelerationRateFast
// W e w a n t s c r o l l v i e w c o n t e n t u p a n d b e h i n d t h e s y s t e m s t a t u s b a r c o n t e n t
// b u t w e w a n t o t h e r c o n t e n t ( e . g . b a r b u t t o n s ) t o r e s p e c t t h e t o p l a y o u t g u i d e .
self . automaticallyAdjustsScrollViewInsets = false
scrollView . autoPinEdgesToSuperviewEdges ( )
let backgroundColor = UIColor . black
self . view . backgroundColor = backgroundColor
// C r e a t e f u l l s c r e e n c o n t a i n e r v i e w s o t h e s c r o l l V i e w
// c a n c o m p u t e a n a p p r o p r i a t e c o n t e n t s i z e i n w h i c h t o c e n t e r
// o u r m e d i a v i e w .
let containerView = UIView . container ( )
scrollView . addSubview ( containerView )
containerView . autoPinEdgesToSuperviewEdges ( )
containerView . autoMatch ( . height , to : . height , of : self . view )
containerView . autoMatch ( . width , to : . width , of : self . view )
containerView . addSubview ( mediaMessageView )
mediaMessageView . autoPinEdgesToSuperviewEdges ( )
if isZoomable {
// A d d t o p a n d b o t t o m g r a d i e n t s t o e n s u r e t o o l b a r c o n t r o l s a r e l e g i b l e
// w h e n p l a c e d o v e r i m a g e / v i d e o p r e v i e w w h i c h m a y b e a c l a s h i n g c o l o r .
let topGradient = GradientView ( from : backgroundColor , to : UIColor . clear )
self . view . addSubview ( topGradient )
topGradient . autoPinWidthToSuperview ( )
topGradient . autoPinEdge ( toSuperviewEdge : . top )
topGradient . autoSetDimension ( . height , toSize : ScaleFromIPhone5 ( 60 ) )
}
// H i d e t h e p l a y b u t t o n e m b e d d e d i n t h e M e d i a V i e w a n d r e p l a c e i t w i t h o u r o w n .
// T h i s a l l o w s u s t o z o o m i n o n t h e m e d i a v i e w w i t h o u t z o o m i n g i n o n t h e b u t t o n
if attachment . isVideo {
guard let videoURL = attachment . dataUrl else {
owsFailDebug ( " Missing videoURL " )
return
}
let player = OWSVideoPlayer ( url : videoURL )
self . videoPlayer = player
player . delegate = self
let playerView = VideoPlayerView ( )
playerView . player = player . avPlayer
self . mediaMessageView . addSubview ( playerView )
playerView . autoPinEdgesToSuperviewEdges ( )
let pauseGesture = UITapGestureRecognizer ( target : self , action : #selector ( didTapPlayerView ( _ : ) ) )
playerView . addGestureRecognizer ( pauseGesture )
let progressBar = PlayerProgressBar ( )
progressBar . player = player . avPlayer
progressBar . delegate = self
// w e d o n ' t w a n t t h e p r o g r e s s b a r t o z o o m d u r i n g " p i n c h - t o - z o o m "
// b u t w e d o w a n t i t t o s h r i n k w i t h t h e m e d i a c o n t e n t w h e n t h e u s e r
// p o p s t h e k e y b o a r d .
contentContainer . addSubview ( progressBar )
progressBar . autoPin ( toTopLayoutGuideOf : self , withInset : 0 )
progressBar . autoPinWidthToSuperview ( )
progressBar . autoSetDimension ( . height , toSize : 44 )
self . mediaMessageView . videoPlayButton ? . isHidden = true
let playButton = UIButton ( )
self . playVideoButton = playButton
playButton . accessibilityLabel = NSLocalizedString ( " PLAY_BUTTON_ACCESSABILITY_LABEL " , comment : " Accessibility label for button to start media playback " )
playButton . setBackgroundImage ( # imageLiteral ( resourceName : " play_button " ) , for : . normal )
playButton . contentMode = . scaleAspectFit
let playButtonWidth = ScaleFromIPhone5 ( 70 )
playButton . autoSetDimensions ( to : CGSize ( width : playButtonWidth , height : playButtonWidth ) )
self . contentContainer . addSubview ( playButton )
playButton . addTarget ( self , action : #selector ( playButtonTapped ) , for : . touchUpInside )
playButton . autoCenterInSuperview ( )
}
}
override public func viewWillLayoutSubviews ( ) {
Logger . debug ( " " )
super . viewWillLayoutSubviews ( )
// e . g . i f f l i p p i n g t o / f r o m l a n d s c a p e
updateMinZoomScaleForSize ( view . bounds . size )
ensureAttachmentViewScale ( animated : false )
}
// MARK:
@objc public func didTapPlayerView ( _ gestureRecognizer : UIGestureRecognizer ) {
assert ( self . videoPlayer != nil )
self . pauseVideo ( )
}
// MARK: - E v e n t H a n d l e r s
@objc
public func playButtonTapped ( ) {
self . playVideo ( )
}
// MARK: V i d e o
private func playVideo ( ) {
Logger . info ( " " )
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 0.0
}
videoPlayer . play ( )
}
private func pauseVideo ( ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . pause ( )
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 1.0
}
}
@objc
public func videoPlayerDidPlayToCompletion ( _ videoPlayer : OWSVideoPlayer ) {
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 1.0
}
}
public func playerProgressBarDidStartScrubbing ( _ playerProgressBar : PlayerProgressBar ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . pause ( )
}
public func playerProgressBar ( _ playerProgressBar : PlayerProgressBar , scrubbedToTime time : CMTime ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . seek ( to : time )
}
public func playerProgressBar ( _ playerProgressBar : PlayerProgressBar , didFinishScrubbingAtTime time : CMTime , shouldResumePlayback : Bool ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . seek ( to : time )
if ( shouldResumePlayback ) {
videoPlayer . play ( )
}
}
// MARK: H e l p e r s
var isZoomable : Bool {
return attachment . isImage || attachment . isVideo
}
func zoomOut ( animated : Bool ) {
if self . scrollView . zoomScale != self . scrollView . minimumZoomScale {
self . scrollView . setZoomScale ( self . scrollView . minimumZoomScale , animated : animated )
}
}
// W h e n t h e k e y b o a r d i s p o p p e d , i t c a n o b s c u r e t h e a t t a c h m e n t v i e w .
// s o w e s o m e t i m e s a l l o w r e s i z i n g t h e a t t a c h m e n t .
var shouldAllowAttachmentViewResizing : Bool = true
var attachmentViewScale : AttachmentViewScale = . fullsize
fileprivate func setAttachmentViewScale ( _ attachmentViewScale : AttachmentViewScale , animated : Bool ) {
self . attachmentViewScale = attachmentViewScale
ensureAttachmentViewScale ( animated : animated )
}
func ensureAttachmentViewScale ( animated : Bool ) {
let animationDuration = animated ? 0.2 : 0
guard shouldAllowAttachmentViewResizing else {
if self . contentContainer . transform != CGAffineTransform . identity {
UIView . animate ( withDuration : animationDuration ) {
self . contentContainer . transform = CGAffineTransform . identity
}
}
return
}
switch attachmentViewScale {
case . fullsize :
guard self . contentContainer . transform != . identity else {
return
}
UIView . animate ( withDuration : animationDuration ) {
self . contentContainer . transform = CGAffineTransform . identity
}
case . compact :
guard self . contentContainer . transform = = . identity else {
return
}
UIView . animate ( withDuration : animationDuration ) {
let kScaleFactor : CGFloat = 0.7
let scale = CGAffineTransform ( scaleX : kScaleFactor , y : kScaleFactor )
let originalHeight = self . scrollView . bounds . size . height
// P o s i t i o n t h e n e w s c a l e d i t e m t o b e c e n t e r e d w i t h r e s p e c t
// t o i t ' s n e w s i z e .
let heightDelta = originalHeight * ( 1 - kScaleFactor )
let translate = CGAffineTransform ( translationX : 0 , y : - heightDelta / 2 )
self . contentContainer . transform = scale . concatenating ( translate )
}
}
}
}
extension AttachmentPrepViewController : UIScrollViewDelegate {
public func viewForZooming ( in scrollView : UIScrollView ) -> UIView ? {
if isZoomable {
return mediaMessageView
} else {
// d o n ' t z o o m f o r a u d i o o r g e n e r i c a t t a c h m e n t s .
return nil
}
}
fileprivate func updateMinZoomScaleForSize ( _ size : CGSize ) {
Logger . debug ( " " )
// E n s u r e b o u n d s h a v e b e e n c o m p u t e d
mediaMessageView . layoutIfNeeded ( )
guard mediaMessageView . bounds . width > 0 , mediaMessageView . bounds . height > 0 else {
Logger . warn ( " bad bounds " )
return
}
let widthScale = size . width / mediaMessageView . bounds . width
let heightScale = size . height / mediaMessageView . bounds . height
let minScale = min ( widthScale , heightScale )
scrollView . maximumZoomScale = minScale * 5.0
scrollView . minimumZoomScale = minScale
scrollView . zoomScale = minScale
}
// K e e p t h e m e d i a v i e w c e n t e r e d w i t h i n t h e s c r o l l v i e w a s y o u z o o m
public func scrollViewDidZoom ( _ scrollView : UIScrollView ) {
// T h e s c r o l l v i e w h a s z o o m e d , s o y o u n e e d t o r e - c e n t e r t h e c o n t e n t s
let scrollViewSize = self . scrollViewVisibleSize
// F i r s t a s s u m e t h a t m e d i a M e s s a g e V i e w c e n t e r c o i n c i d e s w i t h t h e c o n t e n t s c e n t e r
// T h i s i s c o r r e c t w h e n t h e m e d i a M e s s a g e V i e w i s b i g g e r t h a n s c r o l l V i e w d u e t o z o o m
var contentCenter = CGPoint ( x : ( scrollView . contentSize . width / 2 ) , y : ( scrollView . contentSize . height / 2 ) )
let scrollViewCenter = self . scrollViewCenter
// i f m e d i a M e s s a g e V i e w i s s m a l l e r t h a n t h e s c r o l l V i e w v i s i b l e s i z e - f i x t h e c o n t e n t c e n t e r a c c o r d i n g l y
if self . scrollView . contentSize . width < scrollViewSize . width {
contentCenter . x = scrollViewCenter . x
}
if self . scrollView . contentSize . height < scrollViewSize . height {
contentCenter . y = scrollViewCenter . y
}
self . mediaMessageView . center = contentCenter
}
// r e t u r n t h e s c r o l l v i e w c e n t e r
private var scrollViewCenter : CGPoint {
let size = scrollViewVisibleSize
return CGPoint ( x : ( size . width / 2 ) , y : ( size . height / 2 ) )
}
// R e t u r n s c r o l l v i e w s i z e w i t h o u t t h e a r e a o v e r l a p p i n g w i t h t a b a n d n a v b a r .
private var scrollViewVisibleSize : CGSize {
let contentInset = scrollView . contentInset
let scrollViewSize = scrollView . bounds . standardized . size
let width = scrollViewSize . width - ( contentInset . left + contentInset . right )
let height = scrollViewSize . height - ( contentInset . top + contentInset . bottom )
return CGSize ( width : width , height : height )
}
}
protocol CaptioningToolbarDelegate : class {
func captioningToolbarDidTapSend ( _ captioningToolbar : CaptioningToolbar )
func captioningToolbarDidBeginEditing ( _ captioningToolbar : CaptioningToolbar )
func captioningToolbarDidEndEditing ( _ captioningToolbar : CaptioningToolbar )
func captioningToolbar ( _ captioningToolbar : CaptioningToolbar , textViewDidChange : UITextView )
}
class CaptioningToolbar : UIView , UITextViewDelegate {
weak var captioningToolbarDelegate : CaptioningToolbarDelegate ?
private let sendButton : UIButton
private let textView : UITextView
var captionText : String ? {
get { return self . textView . text }
set { self . textView . text = newValue }
}
private let bottomGradient : GradientView
private let lengthLimitLabel : UILabel
// L a y o u t C o n s t a n t s
let kMinTextViewHeight : CGFloat = 38
var maxTextViewHeight : CGFloat {
// A b o u t ~ 4 l i n e s i n p o r t r a i t a n d ~ 3 l i n e s i n l a n d s c a p e .
// O t h e r w i s e w e r i s k o b s c u r i n g t o o m u c h o f t h e c o n t e n t .
return UIDevice . current . orientation . isPortrait ? 160 : 100
}
var textViewHeightConstraint : NSLayoutConstraint !
var textViewHeight : CGFloat
class MessageTextView : UITextView {
// W h e n c r e a t i n g n e w l i n e s , c o n t e n t O f f s e t i s a n i m a t e d , b u t b e c a u s e b e c a u s e
// w e a r e s i m u l t a n e o u s l y r e s i z i n g t h e t e x t v i e w , t h i s c a n c a u s e t h e
// t e x t i n t h e t e x t v i e w t o b e " t o o h i g h " i n t h e t e x t v i e w .
// S o l u t i o n i s t o d i s a b l e a n i m a t i o n f o r s e t t i n g c o n t e n t o f f s e t .
override func setContentOffset ( _ contentOffset : CGPoint , animated : Bool ) {
super . setContentOffset ( contentOffset , animated : false )
}
}
override var intrinsicContentSize : CGSize {
get {
// S i n c e w e h a v e ` s e l f . a u t o r e s i z i n g M a s k = U I V i e w A u t o r e s i z i n g F l e x i b l e H e i g h t ` , w e m u s t s p e c i f y
// a n i n t r i n s i c C o n t e n t S i z e . S p e c i f y i n g C G S i z e . z e r o c a u s e s t h e h e i g h t t o b e d e t e r m i n e d b y a u t o l a y o u t .
return CGSize . zero
}
}
// MARK: I n i t i a l i z e r s
init ( ) {
self . sendButton = UIButton ( type : . system )
self . bottomGradient = GradientView ( from : UIColor . clear , to : UIColor . black )
self . textView = MessageTextView ( )
self . textViewHeight = kMinTextViewHeight
self . lengthLimitLabel = UILabel ( )
super . init ( frame : CGRect . zero )
// S p e c i f y i n g a u t o r s i z i n g m a s k a n d a n i n t r i n s i c c o n t e n t s i z e a l l o w s p r o p e r
// s i z i n g w h e n u s e d a s a n i n p u t a c c e s s o r y v i e w .
self . autoresizingMask = . flexibleHeight
self . translatesAutoresizingMaskIntoConstraints = false
self . backgroundColor = UIColor . clear
textView . delegate = self
textView . keyboardAppearance = Theme . keyboardAppearance
textView . backgroundColor = ( Theme . isDarkThemeEnabled ? UIColor . ows_gray90 : UIColor . ows_gray02 )
textView . layer . borderColor = ( Theme . isDarkThemeEnabled
? Theme . primaryColor . withAlphaComponent ( 0.06 ) . cgColor
: Theme . primaryColor . withAlphaComponent ( 0.12 ) . cgColor )
textView . layer . borderWidth = 0.5
textView . layer . cornerRadius = kMinTextViewHeight / 2
textView . font = UIFont . ows_dynamicTypeBody
textView . textColor = Theme . primaryColor
textView . returnKeyType = . done
textView . textContainerInset = UIEdgeInsets ( top : 7 , left : 7 , bottom : 7 , right : 7 )
textView . scrollIndicatorInsets = UIEdgeInsets ( top : 5 , left : 0 , bottom : 5 , right : 3 )
let sendTitle = NSLocalizedString ( " ATTACHMENT_APPROVAL_SEND_BUTTON " , comment : " Label for 'send' button in the 'attachment approval' dialog. " )
sendButton . setTitle ( sendTitle , for : . normal )
sendButton . addTarget ( self , action : #selector ( didTapSend ) , for : . touchUpInside )
sendButton . titleLabel ? . font = UIFont . ows_mediumFont ( withSize : 16 )
sendButton . titleLabel ? . textAlignment = . center
sendButton . tintColor = UIColor . white
sendButton . backgroundColor = UIColor . ows_systemPrimaryButton
sendButton . layer . cornerRadius = 4
// S e n d B u t t o n S h a d o w - w i t h o u t t h i s t h e s e n d b u t t o n b o t t o m d o e s n ' t f e e l a l i g n e d w i t h t h e t o o l b a r .
let kSendButtonShadowOffset : CGFloat = 1
sendButton . layer . shadowColor = UIColor . darkGray . cgColor
sendButton . layer . shadowOffset = CGSize ( width : 0 , height : kSendButtonShadowOffset )
sendButton . layer . shadowOpacity = 0.8
sendButton . layer . shadowRadius = 0.0
sendButton . layer . masksToBounds = false
// I n c r e a s e h i t a r e a o f s e n d b u t t o n
sendButton . contentEdgeInsets = UIEdgeInsets ( top : 6 , left : 8 , bottom : 6 , right : 8 )
// L e n g t h L i m i t L a b e l s h o w n w h e n t h e u s e r i n p u t s t o o l o n g o f a m e s s a g e
lengthLimitLabel . textColor = . white
lengthLimitLabel . text = NSLocalizedString ( " ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED " , comment : " One-line label indicating the user can add no more text to the attachment caption. " )
lengthLimitLabel . textAlignment = . center
// A d d s h a d o w i n c a s e o v e r l a y e d o n w h i t e c o n t e n t
lengthLimitLabel . layer . shadowColor = UIColor . black . cgColor
lengthLimitLabel . layer . shadowOffset = CGSize ( width : 0.0 , height : 0.0 )
lengthLimitLabel . layer . shadowOpacity = 0.8
self . lengthLimitLabel . isHidden = true
let contentView = UIView ( )
addSubview ( contentView )
contentView . autoPinEdgesToSuperviewEdges ( )
contentView . addSubview ( bottomGradient )
contentView . addSubview ( sendButton )
contentView . addSubview ( textView )
contentView . addSubview ( lengthLimitLabel )
// L a y o u t
let kToolbarMargin : CGFloat = 8
// W e h a v e t o w r a p t h e t o o l b a r i t e m s i n a c o n t e n t v i e w b e c a u s e i O S ( a t l e a s t o n i O S 1 0 . 3 ) a s s i g n s t h e i n p u t A c c e s s o r y V i e w . l a y o u t M a r g i n s
// w h e n r e s i g n i n g f i r s t r e s p o n d e r ( v e r i f i e d b y a u d i t i n g w i t h ` l a y o u t M a r g i n s D i d C h a n g e ` ) .
// T h e e f f e c t o f t h i s i s t h a t i f w e w e r e t o a s s i g n t h e s e m a r g i n s t o s e l f . l a y o u t M a r g i n s , t h e y ' d b e b l o w n a w a y i f t h e
// u s e r d i s m i s s e s t h e k e y b o a r d , g i v i n g t h e i n p u t a c c e s s o r y v i e w a w o n k y l a y o u t .
contentView . layoutMargins = UIEdgeInsets ( top : kToolbarMargin , left : kToolbarMargin , bottom : kToolbarMargin , right : kToolbarMargin )
self . textViewHeightConstraint = textView . autoSetDimension ( . height , toSize : kMinTextViewHeight )
// W e p i n a l l t h r e e e d g e s e x p l i c i t l y r a t h e r t h a n d o i n g s o m e t h i n g l i k e :
// t e x t V i e w . a u t o P i n E d g e s ( t o S u p e r v i e w M a r g i n s E x c l u d i n g E d g e : . r i g h t )
// b e c a u s e t h a t m e t h o d u s e s ` l e a d i n g ` / ` t r a i l i n g ` r a t h e r t h a n ` l e f t ` v s . ` r i g h t ` .
// S o i t d o e s n ' t w o r k a s e x p e c t e d w i t h R T L l a y o u t s w h e n w e e x p l i c i t l y w a n t s o m e t h i n g
// t o b e o n t h e r i g h t s i d e f o r b o t h R T L a n d L T R l a y o u t s , l i k e w i t h t h e s e n d b u t t o n .
// I b e l i e v e t h i s i s a b u g i n P u r e L a y o u t . F i l e d h e r e : h t t p s : / / g i t h u b . c o m / P u r e L a y o u t / P u r e L a y o u t / i s s u e s / 2 0 9
textView . autoPinEdge ( toSuperviewMargin : . left )
textView . autoPinEdge ( toSuperviewMargin : . top )
textView . autoPinEdge ( toSuperviewMargin : . bottom )
sendButton . autoPinEdge ( . left , to : . right , of : textView , withOffset : kToolbarMargin )
// B e c a u s e t h e t e x t v i e w h a s a b o r d e r , t h e s e n d B u t t o n f e e l s u n a l i g n e d w i t h o u t t h i s s h a d o w a n d o f f s e t
sendButton . autoPinEdge ( . bottom , to : . bottom , of : textView , withOffset : - kSendButtonShadowOffset )
sendButton . autoPinEdge ( toSuperviewMargin : . right )
sendButton . setContentHuggingHigh ( )
sendButton . setCompressionResistanceHigh ( )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . left )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . right )
lengthLimitLabel . autoPinEdge ( . bottom , to : . top , of : textView , withOffset : - 6 )
lengthLimitLabel . setContentHuggingHigh ( )
lengthLimitLabel . setCompressionResistanceHigh ( )
let bottomGradientHeight = ScaleFromIPhone5 ( 100 )
bottomGradient . autoSetDimension ( . height , toSize : bottomGradientHeight )
bottomGradient . autoPinEdgesToSuperviewEdges ( with : . zero , excludingEdge : . top )
}
required init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
// MARK:
@objc func didTapSend ( ) {
self . captioningToolbarDelegate ? . captioningToolbarDidTapSend ( self )
}
// MARK: - U I T e x t V i e w D e l e g a t e
public func textViewDidChange ( _ textView : UITextView ) {
updateHeight ( textView : textView )
self . captioningToolbarDelegate ? . captioningToolbar ( self , textViewDidChange : textView )
}
public func textView ( _ textView : UITextView , shouldChangeTextIn range : NSRange , replacementText text : String ) -> Bool {
let existingText : String = textView . text ? ? " "
let proposedText : String = ( existingText as NSString ) . replacingCharacters ( in : range , with : text )
guard proposedText . utf8 . count <= kOversizeTextMessageSizeThreshold else {
Logger . debug ( " long text was truncated " )
self . lengthLimitLabel . isHidden = false
// ` r a n g e ` r e p r e s e n t s t h e s e c t i o n o f t h e e x i s t i n g t e x t w e w i l l r e p l a c e . W e c a n r e - u s e t h a t s p a c e .
// R a n g e i s i n u n i t s o f N S S t r i n g s ' s s t a n d a r d U T F - 1 6 c h a r a c t e r s . S i n c e s o m e o f t h o s e c h a r s c o u l d b e
// r e p r e s e n t e d a s s i n g l e b y t e s i n u t f - 8 , w h i l e o t h e r s m a y b e 8 o r m o r e , t h e o n l y w a y t o b e s u r e i s
// t o j u s t m e a s u r e t h e u t f 8 e n c o d e d b y t e s o f t h e r e p l a c e d s u b s t r i n g .
let bytesAfterDelete : Int = ( existingText as NSString ) . replacingCharacters ( in : range , with : " " ) . utf8 . count
// A c c e p t a s m u c h o f t h e i n p u t a s w e c a n
let byteBudget : Int = Int ( kOversizeTextMessageSizeThreshold ) - bytesAfterDelete
if byteBudget >= 0 , let acceptableNewText = text . truncated ( toByteCount : UInt ( byteBudget ) ) {
textView . text = ( existingText as NSString ) . replacingCharacters ( in : range , with : acceptableNewText )
}
return false
}
self . lengthLimitLabel . isHidden = true
// T h o u g h w e c a n w r a p t h e t e x t , w e d o n ' t w a n t t o e n c o u r a g e m u l t l i n e c a p t i o n s , p l u s a " d o n e " b u t t o n
// a l l o w s t h e u s e r t o g e t t h e k e y b o a r d o u t o f t h e w a y w h i l e i n t h e a t t a c h m e n t a p p r o v a l v i e w .
if text = = " \n " {
textView . resignFirstResponder ( )
return false
} else {
return true
}
}
public func textViewDidBeginEditing ( _ textView : UITextView ) {
self . captioningToolbarDelegate ? . captioningToolbarDidBeginEditing ( self )
}
public func textViewDidEndEditing ( _ textView : UITextView ) {
self . captioningToolbarDelegate ? . captioningToolbarDidEndEditing ( self )
}
// MARK: - H e l p e r s
private func updateHeight ( textView : UITextView ) {
// c o m p u t e n e w h e i g h t a s s u m i n g w i d t h i s u n c h a n g e d
let currentSize = textView . frame . size
let newHeight = clampedTextViewHeight ( fixedWidth : currentSize . width )
if newHeight != self . textViewHeight {
Logger . debug ( " TextView height changed: \( self . textViewHeight ) -> \( newHeight ) " )
self . textViewHeight = newHeight
self . textViewHeightConstraint ? . constant = textViewHeight
self . invalidateIntrinsicContentSize ( )
}
}
private func clampedTextViewHeight ( fixedWidth : CGFloat ) -> CGFloat {
let contentSize = textView . sizeThatFits ( CGSize ( width : fixedWidth , height : CGFloat . greatestFiniteMagnitude ) )
return CGFloatClamp ( contentSize . height , kMinTextViewHeight , maxTextViewHeight )
}
}