//
// 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
import PromiseKit
@objc
public protocol AttachmentApprovalViewControllerDelegate : class {
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didApproveAttachments attachments : [ SignalAttachment ] , messageText : String ? )
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didCancelAttachments attachments : [ SignalAttachment ] )
@objc optional func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , addMoreToAttachments attachments : [ SignalAttachment ] )
@objc optional func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , changedCaptionOfAttachment attachment : SignalAttachment )
}
// MARK: -
class AttachmentItemCollection {
private ( set ) var attachmentItems : [ SignalAttachmentItem ]
init ( attachmentItems : [ SignalAttachmentItem ] ) {
self . attachmentItems = attachmentItems
}
func itemAfter ( item : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : item ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let nextIndex = attachmentItems . index ( after : currentIndex )
return attachmentItems [ safe : nextIndex ]
}
func itemBefore ( item : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : item ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let prevIndex = attachmentItems . index ( before : currentIndex )
return attachmentItems [ safe : prevIndex ]
}
func remove ( item : SignalAttachmentItem ) {
attachmentItems = attachmentItems . filter { $0 != item }
}
}
// MARK: -
class SignalAttachmentItem : Hashable {
enum SignalAttachmentItemError : Error {
case noThumbnail
}
let attachment : SignalAttachment
var imageEditorModel : ImageEditorModel ?
init ( attachment : SignalAttachment ) {
self . attachment = attachment
// T r y a n d m a k e a I m a g e E d i t o r M o d e l .
// T h i s w i l l o n l y a p p l y f o r v a l i d i m a g e s .
if let dataUrl : URL = attachment . dataUrl ,
dataUrl . isFileURL {
let path = dataUrl . path
do {
imageEditorModel = try ImageEditorModel ( srcImagePath : path )
} catch {
// U s u a l l y n o t a n e r r o r .
Logger . warn ( " Could not create image editor: \( error ) " )
}
}
}
// MARK:
var captionText : String ? {
return attachment . captionText
}
var imageSize : CGSize = . zero
func getThumbnailImage ( ) -> Promise < UIImage > {
return DispatchQueue . global ( ) . async ( . promise ) { ( ) -> UIImage in
guard let image = self . attachment . staticThumbnail ( ) else {
throw SignalAttachmentItemError . noThumbnail
}
return image
} . tap { result in
switch result {
case . fulfilled ( let image ) :
self . imageSize = image . size
case . rejected ( let error ) :
owsFailDebug ( " failed with error: \( error ) " )
}
}
}
// MARK: H a s h a b l e
public var hashValue : Int {
return attachment . hashValue
}
// MARK: E q u a t a b l e
static func = = ( lhs : SignalAttachmentItem , rhs : SignalAttachmentItem ) -> Bool {
return lhs . attachment = = rhs . attachment
}
}
// MARK: -
@objc
public enum AttachmentApprovalViewControllerMode : UInt {
case modal
case sharedNavigation
}
// MARK: -
@objc
public class AttachmentApprovalViewController : UIPageViewController , UIPageViewControllerDataSource , UIPageViewControllerDelegate {
// MARK: - P r o p e r t i e s
private let mode : AttachmentApprovalViewControllerMode
public weak var approvalDelegate : AttachmentApprovalViewControllerDelegate ?
// 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 ( mode : AttachmentApprovalViewControllerMode ,
attachments : [ SignalAttachment ] ) {
assert ( attachments . count > 0 )
self . mode = mode
let attachmentItems = attachments . map { SignalAttachmentItem ( attachment : $0 ) }
self . attachmentItemCollection = AttachmentItemCollection ( attachmentItems : attachmentItems )
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 ( mode : . modal , 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 . overrideTheme ( type : . clear )
return navController
}
// MARK: - S u b v i e w s
var galleryRailView : GalleryRailView {
return bottomToolView . galleryRailView
}
var mediaMessageTextToolbar : MediaMessageTextToolbar {
return bottomToolView . mediaMessageTextToolbar
}
lazy var bottomToolView : BottomToolView = {
let isAddMoreVisible = mode = = . sharedNavigation
let bottomToolView = BottomToolView ( isAddMoreVisible : isAddMoreVisible )
return bottomToolView
} ( )
// MARK: - V i e w L i f e c y c l e
override public func viewDidLoad ( ) {
super . viewDidLoad ( )
self . view . backgroundColor = . black
// a v o i d 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 .
pagerScrollView ? . isScrollEnabled = attachmentItems . count > 1
// B o t t o m T o o l b a r
galleryRailView . delegate = self
mediaMessageTextToolbar . mediaMessageTextToolbarDelegate = self
// N a v i g a t i o n
self . navigationItem . title = nil
if mode != . sharedNavigation {
let cancelButton = UIBarButtonItem ( barButtonSystemItem : . cancel ,
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 )
// A s a r e f r e s h e r , t h e _ I n f o r m a t i o n A r c h i t e c t u r e _ h e r e i s :
//
// Y o u a r e a p p r o v i n g a n " A l b u m " , w h i c h h a s m u l t i p l e " A t t a c h m e n t s "
//
// T h e " m e d i a m e s s a g e t e x t " a n d t h e " m e d i a r a i l " b e l o n g t o t h e A l b u m a s a w h o l e , w h e r e a s
// e a c h c a p t i o n b e l o n g s t o t h e i n d i v i d u a l A t t a c h m e n t .
//
// T h e _ U I A r c h i t e c t u r e _ r e f l e c t s t h i s h i e r a r c h y b y p u t t i n g t h e M e d i a R a i l a n d
// M e d i a M e s s a g e T e x t i n p u t i n t o t h e b o t t o m T o o l V i e w w h i c h i s t h e 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 ' s
// i n p u t A c c e s s o r y V i e w .
//
// W h e r e a s a C a p t i o n V i e w l i v e s i n e a c h p a g e o f t h e P a g e V i e w C o n t r o l l e r , p e r A t t a c h m e n t .
//
// S o a s y o u p a g e , t h e C a p t i o n V i e w s m o v e o u t o f v i e w w i t h i t s p a g e , w h e r e a s t h e i n p u t
// a c c e s s o r y v i e w ( r a i l / m e d i a m e s s a g e t e x t ) w i l l r e m a i n f i x e d i n t h e v i e w p o r t .
//
// H o w e v e r ( a n d h e r e ' s t h e k i c k e r ) , a t r e s t , t h e m e d i a ' s C a p t i o n V i e w r e s t s j u s t a b o v e t h e
// i n p u t a c c e s s o r y v i e w . S o w h e n t h i n g s a r e s t a t i c , t h e y a p p e a r a s a s i n g l e p i e c e o f
// i n t e r f a c e .
//
// I ' m n o t t o t a l l y s u r e i f t h i s i s w h a t M y l e s h a d i n m i n d , b u t t h e s c r e e n s h o t s l e f t a l o t o f
// b e h a v i o r a m b i g u o u s , a n d t h i s w a s m y b e s t i n t e r p r e t a t i o n .
//
// B e c a u s e o f t h i s c o m p l e x i t y , i t i s i n s u f f i c i e n t t o o b s e r v e o n l y t h e
// K e y b o a r d W i l l C h a n g e F r a m e , s i n c e t h e k e y b o a r d c o u l d b e c h a n g i n g f r a m e w h e n t h e C a p t i o n V i e w
// b e c a m e / r e s i g n e d f i r s t r e s p o n d e r , w h e n A t t a c h m e n t A p p r o v a l V i e w C o n t r o l l e r b e c a m e / r e s i g n e d
// f i r s t r e s p o n d e r , o r w h e 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 ' s i n p u t A c c e s s o r y V i e w . t e x t V i e w
// b e c a m e / r e s i g n e d f i r s t r e s p o n d e r , a n d b e c a u s e t h e s e t h i n g s c a n h a p p e n i n i m m e d i a t r e
// s e q u e n c e , g e t t i n g a s i n g l e s m o o t h a n i m a t i o n r e q u i r e s h a n d l i n g e a c h n o t i f i c a t i o n s l i g h t l y
// d i f f e r e n t l y .
NotificationCenter . default . addObserver ( self ,
selector : #selector ( keyboardWillShow ( notification : ) ) ,
name : . UIKeyboardWillShow ,
object : nil )
NotificationCenter . default . addObserver ( self ,
selector : #selector ( keyboardDidShow ( notification : ) ) ,
name : . UIKeyboardDidShow ,
object : nil )
NotificationCenter . default . addObserver ( self ,
selector : #selector ( keyboardWillHide ( notification : ) ) ,
name : . UIKeyboardWillHide ,
object : nil )
}
override public func viewWillAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillAppear ( animated )
CurrentAppContext ( ) . setStatusBarHidden ( true , animated : animated )
guard let navigationBar = navigationController ? . navigationBar as ? OWSNavigationBar else {
owsFailDebug ( " navigationBar was nil or unexpected class " )
return
}
navigationBar . overrideTheme ( type : . clear )
updateCaptionVisibility ( )
}
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 ? {
bottomToolView . layoutIfNeeded ( )
return bottomToolView
}
override public var canBecomeFirstResponder : Bool {
return true
}
var lastObservedKeyboardTop : CGFloat = 0
var inputAccessorySnapshotView : UIView ?
@objc
func keyboardDidShow ( notification : Notification ) {
// I f t h i s i s a r e s u l t o f t h e v c b e c o m i n g f i r s t r e s p o n d e r , t h e k e y b o a r d i s n ' t a c t u a l l y
// s h o w i n g , r a t h e r t h e i n p u t A c c e s s o r y V i e w i s n o w s h o w i n g , s o w e w a n t t o r e m o v e a n y
// p r e v i o u s l y a d d e d t o o l b a r s n a p s h o t .
if isFirstResponder , inputAccessorySnapshotView != nil {
removeToolbarSnapshot ( )
}
}
@objc
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
func keyboardWillShow ( notification : Notification ) {
guard let userInfo = notification . userInfo else {
owsFailDebug ( " userInfo was unexpectedly nil " )
return
}
guard let keyboardStartFrame = userInfo [ UIKeyboardFrameBeginUserInfoKey ] as ? CGRect else {
owsFailDebug ( " keyboardEndFrame was unexpectedly nil " )
return
}
guard let keyboardEndFrame = userInfo [ UIKeyboardFrameEndUserInfoKey ] as ? CGRect else {
owsFailDebug ( " keyboardEndFrame was unexpectedly nil " )
return
}
Logger . debug ( " \( keyboardStartFrame ) -> \( keyboardEndFrame ) " )
lastObservedKeyboardTop = keyboardEndFrame . size . height
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
let keyboardScenario : KeyboardScenario = bottomToolView . isEditingMediaMessage ? . editingMessage : . editingCaption
currentPageViewController . updateCaptionViewBottomInset ( keyboardScenario : keyboardScenario )
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
}
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
@objc
func keyboardWillHide ( notification : Notification ) {
guard let userInfo = notification . userInfo else {
owsFailDebug ( " userInfo was unexpectedly nil " )
return
}
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
guard let keyboardStartFrame = userInfo [ UIKeyboardFrameBeginUserInfoKey ] as ? CGRect else {
owsFailDebug ( " keyboardEndFrame was unexpectedly nil " )
return
}
guard let keyboardEndFrame = userInfo [ UIKeyboardFrameEndUserInfoKey ] as ? CGRect else {
owsFailDebug ( " keyboardEndFrame was unexpectedly nil " )
return
}
WIP: hide caption keyboard
It's tricky because we're hopping from one first responder to another.
Specifically, from the CaptionView.textView, which shows the keyboard, to
making the AttachmentApprovalViewController first responder, which shows the
BottomToolbar message text field, so in short order, we're getting multiple
notifications.
User hit's "Done" with caption
- Point A - CaptionView is positioned at the top of the keyboard
- Hide keyboard (frame change details must be calculated by y offset, since willChanage notification doesn't "shrink" the keyboard frame, it just offsets it to be non-visible.
- Point B - caption view is positioned at the bottom of the screen, input accessory view not visible
- Show Keyboard (not actually showing the *keyboard* here, but rather the VC's input accessory view)
- Point C - caption view is positioned atop the input accessory view
We want to animated smoothly from A->C, skipping B. But how do we do that robustly? We could track something like "last known input accessory view height" and never present the captionView below that. But I'm worried it won't be very robust since the input accessory view can change height, e.g. text view grows with text content or dynamic text changes.
7 years ago
Logger . debug ( " \( keyboardStartFrame ) -> \( keyboardEndFrame ) " )
lastObservedKeyboardTop = UIScreen . main . bounds . height - keyboardEndFrame . size . height
currentPageViewController . updateCaptionViewBottomInset ( keyboardScenario : . hidden )
}
// MARK: - V i e w H e l p e r s
func remove ( attachmentItem : SignalAttachmentItem ) {
if attachmentItem = = currentItem {
if let nextItem = attachmentItemCollection . itemAfter ( item : attachmentItem ) {
setCurrentItem ( nextItem , direction : . forward , animated : true )
} else if let prevItem = attachmentItemCollection . itemBefore ( item : attachmentItem ) {
setCurrentItem ( prevItem , direction : . reverse , animated : true )
} else {
owsFailDebug ( " removing last item shouldn't be possible because rail should not be visible " )
return
}
}
guard let cell = galleryRailView . cellViews . first ( where : { $0 . item = = = attachmentItem } ) else {
owsFailDebug ( " cell was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.2 ,
animations : {
// s h r i n k s t a c k v i e w i t e m u n t i l i t d i s a p p e a r s
cell . isHidden = true
// s i m u l t a n e o u s l y f a d e o u t
cell . alpha = 0
} ,
completion : { _ in
self . attachmentItemCollection . remove ( item : attachmentItem )
self . updateMediaRail ( )
} )
}
func addDeleteIcon ( cellView : GalleryRailCellView ) {
guard let attachmentItem = cellView . item as ? SignalAttachmentItem else {
owsFailDebug ( " attachmentItem was unexpectedly nil " )
return
}
let button = OWSButton { [ weak self ] in
guard let strongSelf = self else { return }
strongSelf . remove ( attachmentItem : attachmentItem )
}
button . setImage ( # imageLiteral ( resourceName : " ic_small_x " ) , for : . normal )
let kInsetDistance : CGFloat = 5
button . imageEdgeInsets = UIEdgeInsets ( top : kInsetDistance , left : kInsetDistance , bottom : kInsetDistance , right : kInsetDistance )
cellView . addSubview ( button )
let kButtonWidth : CGFloat = 9 + kInsetDistance * 2
button . autoSetDimensions ( to : CGSize ( width : kButtonWidth , height : kButtonWidth ) )
button . autoPinEdge ( toSuperviewMargin : . top )
button . autoPinEdge ( toSuperviewMargin : . trailing )
}
lazy 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 .
let pagerScrollView = view . subviews . first { $0 is UIScrollView } as ? UIScrollView
assert ( pagerScrollView != nil )
return pagerScrollView
} ( )
func updateCaptionVisibility ( ) {
for pageViewController in pageViewControllers {
pageViewController . updateCaptionVisibility ( attachmentCount : attachments . count )
}
}
// 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 )
let keyboardScenario : KeyboardScenario = bottomToolView . isEditingMediaMessage ? . editingMessage : . hidden
pendingPage . updateCaptionViewBottomInset ( keyboardScenario : keyboardScenario )
}
}
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 {
previousPage . zoomOut ( animated : false )
updateMediaRail ( )
}
}
}
// 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 currentPageViewController : AttachmentPrepViewController {
return pageViewControllers . first !
}
public var pageViewControllers : [ AttachmentPrepViewController ] {
return super . viewControllers ! . map { $0 as ! AttachmentPrepViewController }
}
var currentItem : SignalAttachmentItem ! {
get {
return currentPageViewController . 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 )
viewController . prepDelegate = self
viewController . updateCaptionVisibility ( attachmentCount : attachments . count )
cachedPages [ item ] = viewController
return viewController
}
private func setCurrentItem ( _ item : SignalAttachmentItem , direction : UIPageViewControllerNavigationDirection , animated isAnimated : Bool ) {
guard let page = self . buildPage ( item : item ) else {
owsFailDebug ( " unexpectedly unable to build new page " )
return
}
self . setViewControllers ( [ page ] , direction : direction , animated : isAnimated , completion : nil )
updateMediaRail ( )
}
func updateMediaRail ( ) {
guard let currentItem = self . currentItem else {
owsFailDebug ( " currentItem was unexpectedly nil " )
return
}
let cellViewDecoratorBlock = { ( cellView : GalleryRailCellView ) in
self . addDeleteIcon ( cellView : cellView )
}
galleryRailView . configureCellViews ( itemProvider : attachmentItemCollection ,
focusedItem : currentItem ,
cellViewDecoratorBlock : cellViewDecoratorBlock )
galleryRailView . isHidden = attachmentItemCollection . attachmentItems . count < 2
}
let attachmentItemCollection : AttachmentItemCollection
var attachmentItems : [ SignalAttachmentItem ] {
return attachmentItemCollection . attachmentItems
}
var attachments : [ SignalAttachment ] {
return attachmentItems . map { self . attachment ( forAttachmentItem : $0 ) }
}
// F o r a n y a t t a c h m e n t s e d i t e d w i t h t h e i m a g e e d i t o r , r e t u r n s a
// n e w S i g n a l A t t a c h m e n t t h a t r e f l e c t s t h o s e c h a n g e s . O t h e r w i s e ,
// r e t u r n s t h e o r i g i n a l a t t a c h m e n t .
//
// I f a n y e r r o r s o c c u r s i n t h e e x p o r t p r o c e s s , w e f a i l o v e r t o
// s e n d i n g t h e o r i g i n a l a t t a c h m e n t . T h i s s e e m s b e t t e r t h a n t r y i n g
// t o i n v o l v e t h e u s e r i n r e s o l v i n g t h e i s s u e .
func attachment ( forAttachmentItem attachmentItem : SignalAttachmentItem ) -> SignalAttachment {
guard let imageEditorModel = attachmentItem . imageEditorModel else {
// I m a g e w a s n o t e d i t e d .
return attachmentItem . attachment
}
guard imageEditorModel . itemCount ( ) > 0 else {
// I m a g e e d i t o r h a s n o c h a n g e s .
return attachmentItem . attachment
}
guard let dstImage = ImageEditorView . renderForOutput ( model : imageEditorModel ) else {
owsFailDebug ( " Could not render for output. " )
return attachmentItem . attachment
}
var dataUTI = kUTTypeImage as String
guard let dstData : Data = {
let isLossy : Bool = attachmentItem . attachment . mimeType . caseInsensitiveCompare ( OWSMimeTypeImageJpeg ) = = . orderedSame
if isLossy {
dataUTI = kUTTypeJPEG as String
return UIImageJPEGRepresentation ( dstImage , 0.9 )
} else {
dataUTI = kUTTypePNG as String
return UIImagePNGRepresentation ( dstImage )
}
} ( ) else {
owsFailDebug ( " Could not export for output. " )
return attachmentItem . attachment
}
guard let dataSource = DataSourceValue . dataSource ( with : dstData , utiType : dataUTI ) else {
owsFailDebug ( " Could not prepare data source for output. " )
return attachmentItem . attachment
}
// R e w r i t e t h e f i l e n a m e ' s e x t e n s i o n t o r e f l e c t t h e o u t p u t f i l e f o r m a t .
var filename : String ? = attachmentItem . attachment . sourceFilename
if let sourceFilename = attachmentItem . attachment . sourceFilename {
if let fileExtension : String = MIMETypeUtil . fileExtension ( forUTIType : dataUTI ) {
filename = ( sourceFilename as NSString ) . deletingPathExtension . appendingFileExtension ( fileExtension )
}
}
dataSource . sourceFilename = filename
let dstAttachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : dataUTI , imageQuality : . original )
if let attachmentError = dstAttachment . error {
owsFailDebug ( " Could not prepare attachment for output: \( attachmentError ) . " )
return attachmentItem . attachment
}
return dstAttachment
}
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 )
}
}
extension AttachmentApprovalViewController : MediaMessageTextToolbarDelegate {
func mediaMessageTextToolbarDidBeginEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
currentPageViewController . setAttachmentViewScale ( . compact , animated : true )
}
func mediaMessageTextToolbarDidEndEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
currentPageViewController . setAttachmentViewScale ( . fullsize , animated : true )
}
func mediaMessageTextToolbarDidTapSend ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
// 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 .
currentPageViewController . shouldAllowAttachmentViewResizing = false
mediaMessageTextToolbar . isUserInteractionEnabled = false
mediaMessageTextToolbar . isHidden = true
approvalDelegate ? . attachmentApproval ( self , didApproveAttachments : attachments , messageText : mediaMessageTextToolbar . messageText )
}
func mediaMessageTextToolbarDidAddMore ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
self . approvalDelegate ? . attachmentApproval ? ( self , addMoreToAttachments : attachments )
}
}
extension AttachmentApprovalViewController : AttachmentPrepViewControllerDelegate {
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didUpdateCaptionForAttachmentItem attachmentItem : SignalAttachmentItem ) {
self . approvalDelegate ? . attachmentApproval ? ( self , changedCaptionOfAttachment : attachmentItem . attachment )
}
func prepViewController ( _ prepViewController : AttachmentPrepViewController , willBeginEditingCaptionView captionView : CaptionView ) {
// W h e n t h e C a p t i o n V i e w b e c o m e s f i r s t r e s p o n d e r , 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 C o n t r o l l e r w i l l
// c o n s e q u e n t l y r e s i g n F i r s t R e s p o n d e r , w h i c h m e a n s t h e b o t t o m T o o l V i e w w o u l d d i s a p p e a r f r o m
// t h e s c r e e n , s o b e f o r e t h a t h a p p e n s , w e a d d a s n a p s h o t t o h o l d s i t ' s p l a c e .
addInputAccessorySnapshot ( )
}
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didBeginEditingCaptionView captionView : CaptionView ) {
// D i s a b l e p a g i n g w h i l e c a p t i o n s a r e b e i n g e d i t e d t o a v o i d a c l u n k y a n i m a t i o n .
//
// L o a d i n g t h e n e x t p a g e c a u s e s t h e C a p t i o n V i e w t o r e s i g n f i r s t r e s p o n d e r , w h i c h i n t u r n
// d i s m i s s e s t h e k e y b o a r d , w h i c h i n t u r n a f f e c t s t h e v e r t i c a l o f f s e t o f b o t h t h e C a p t i o n V i e w
// f r o m t h e p a g e w e ' r e l e a v i n g a s w e l l a s t h e p a g e w e ' r e e n t e r i n g . I n s t e a d w e r e q u i r e t h e
// u s e r t o d i s m i s s * t h e n * s w i p e .
disablePaging ( )
}
func addInputAccessorySnapshot ( ) {
assert ( inputAccessorySnapshotView = = nil )
// T o f i x a l a y o u t g l i t c h w h e r e t h e s n a p s h o t v i e w i s 1 / 2 t h e w i d t h o f t h e s c r e e n , i t ' s k e y
// t h a t w e u s e ` b o t t o m T o o l V i e w ` a n d n o t ` i n p u t A c c e s s o r y V i e w ` w h i c h c a n t r i g g e r a l a y o u t o f
// t h e ` b o t t o m T o o l V i e w ` .
// P r e s u m a b l y t h e f r a m e o f t h e i n p u t A c c e s s o r y V i e w h a s j u s t c h a n g e d b e c a u s e w e ' r e i n t h e
// m i d d l e o f s w i t c h i n g f i r s t r e s p o n d e r s . W e w a n t a s n a p s h o t a s i t * w a s * , n o t r e f l e c t i n g a n y
// j u s t - a p p l i e d s u p e r v i e w l a y o u t c h a n g e s .
inputAccessorySnapshotView = bottomToolView . snapshotView ( afterScreenUpdates : true )
guard let inputAccessorySnapshotView = inputAccessorySnapshotView else {
owsFailDebug ( " inputAccessorySnapshotView was unexpectedly nil " )
return
}
view . addSubview ( inputAccessorySnapshotView )
inputAccessorySnapshotView . autoSetDimension ( . height , toSize : bottomToolView . bounds . height )
inputAccessorySnapshotView . autoPinEdgesToSuperviewEdges ( with : . zero , excludingEdge : . top )
}
func removeToolbarSnapshot ( ) {
guard let inputAccessorySnapshotView = self . inputAccessorySnapshotView else {
owsFailDebug ( " inputAccessorySnapshotView was unexpectedly nil " )
return
}
inputAccessorySnapshotView . removeFromSuperview ( )
self . inputAccessorySnapshotView = nil
}
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didEndEditingCaptionView captionView : CaptionView ) {
enablePaging ( )
}
func desiredCaptionViewBottomInset ( keyboardScenario : KeyboardScenario ) -> CGFloat {
switch keyboardScenario {
case . hidden , . editingMessage :
return bottomToolView . bounds . height
case . editingCaption :
return lastObservedKeyboardTop
}
}
// MARK: H e l p e r s
func disablePaging ( ) {
pagerScrollView ? . panGestureRecognizer . isEnabled = false
}
func enablePaging ( ) {
pagerScrollView ? . panGestureRecognizer . isEnabled = true
}
}
// MARK: G a l l e r y R a i l
extension SignalAttachmentItem : GalleryRailItem {
var aspectRatio : CGFloat {
return self . imageSize . aspectRatio
}
func getRailImage ( ) -> Promise < UIImage > {
return self . getThumbnailImage ( )
}
}
extension AttachmentItemCollection : GalleryRailItemProvider {
var railItems : [ GalleryRailItem ] {
return self . attachmentItems
}
}
extension AttachmentApprovalViewController : GalleryRailViewDelegate {
public func galleryRailView ( _ galleryRailView : GalleryRailView , didTapItem imageRailItem : GalleryRailItem ) {
guard let targetItem = imageRailItem as ? SignalAttachmentItem else {
owsFailDebug ( " unexpected imageRailItem: \( imageRailItem ) " )
return
}
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return
}
guard let targetIndex = attachmentItems . index ( of : targetItem ) else {
owsFailDebug ( " targetIndex was unexpectedly nil " )
return
}
let direction : UIPageViewControllerNavigationDirection = currentIndex < targetIndex ? . forward : . reverse
self . setCurrentItem ( targetItem , direction : direction , animated : true )
}
}
// MARK: - I n d i v i d u a l P a g e
enum KeyboardScenario {
case hidden , editingMessage , editingCaption
}
protocol AttachmentPrepViewControllerDelegate : class {
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didUpdateCaptionForAttachmentItem attachmentItem : SignalAttachmentItem )
func prepViewController ( _ prepViewController : AttachmentPrepViewController , willBeginEditingCaptionView captionView : CaptionView )
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didBeginEditingCaptionView captionView : CaptionView )
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didEndEditingCaptionView captionView : CaptionView )
func desiredCaptionViewBottomInset ( keyboardScenario : KeyboardScenario ) -> CGFloat
}
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
weak var prepDelegate : AttachmentPrepViewControllerDelegate ?
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 ?
private var imageEditorView : ImageEditorView ?
// 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 " )
}
func updateCaptionVisibility ( attachmentCount : Int ) {
if attachmentCount > 1 {
captionView . isHidden = false
return
}
// I f w e p r e v i o u s l y h a d m u l t i p l e a t t a c h m e n t s , w e ' d h a v e s h o w n t h e c a p t i o n f i e l d s .
//
// S u b s e q u e n t l y , i f t h e u s e r h a d a d d e d c a p t i o n t e x t , t h e n r e m o v e d t h e o t h e r a t t a c h m e n t s
// w e w i l l c o n t i n u e t o s h o w t h i s c a p t i o n f i e l d , s o a s n o t t o h i d e a n y a l r e a d y - e n t e r e d t e x t .
if let captionText = captionView . captionText , captionText . count > 0 {
captionView . isHidden = false
return
}
captionView . isHidden = true
}
// MARK: - S u b v i e w s
lazy var captionView : CaptionView = {
return CaptionView ( attachmentItem : attachmentItem )
} ( )
lazy var touchInterceptorView : UIView = {
let touchInterceptorView = UIView ( )
let tapGesture = UITapGestureRecognizer ( target : self , action : #selector ( didTapTouchInterceptorView ( gesture : ) ) )
touchInterceptorView . addGestureRecognizer ( tapGesture )
return touchInterceptorView
} ( )
// 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 let imageEditorModel = attachmentItem . imageEditorModel ,
let imageMediaView = self . mediaMessageView . contentView {
let imageEditorView = ImageEditorView ( model : imageEditorModel )
imageMediaView . isUserInteractionEnabled = true
imageMediaView . addSubview ( imageEditorView )
imageEditorView . autoPinEdgesToSuperviewEdges ( )
self . imageEditorView = imageEditorView
}
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 ( )
}
// C a p t i o n
view . addSubview ( touchInterceptorView )
touchInterceptorView . autoPinEdgesToSuperviewEdges ( )
touchInterceptorView . isHidden = true
view . addSubview ( captionView )
captionView . delegate = self
captionView . autoPinWidthToSuperview ( )
captionViewBottomConstraint = captionView . autoPinEdge ( toSuperviewEdge : . bottom )
}
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: C a p t i o n V i e w l i f t s w i t h k e y b o a r d
var hasLaidOutCaptionView : Bool = false
var captionViewBottomConstraint : NSLayoutConstraint !
func updateCaptionViewBottomInset ( keyboardScenario : KeyboardScenario ) {
guard let prepDelegate = self . prepDelegate else {
owsFailDebug ( " prepDelegate was unexpectedly nil " )
return
}
let changeBlock = {
let offset : CGFloat = - 1 * prepDelegate . desiredCaptionViewBottomInset ( keyboardScenario : keyboardScenario )
self . captionViewBottomConstraint . constant = offset
self . captionView . superview ? . layoutIfNeeded ( )
}
// T o a v o i d a n a n i m a t i o n g l i t c h , w e a p p l y t h i s u p d a t e w i t h o u t a n i m a t i o n b e f o r e i n i t i a l
// a p p e a r a n c e . B u t a f t e r t h a t , w e w a n t t o a p p l y t h e c o n s t r a i n t c h a n g e w i t h i n t h e e x i s t i n g
// a n i m a t i o n c o n t e x t , s i n c e w e c a l l t h i s w h i l e h a n d l i n g a U I K e y b o a r d n o t i f i c a t i o n , w h i c h
// a l l o w s u s t o s l i d e u p t h e C a p t i o n V i e w i n l o c k s t e p w i t h t h e k e y b o a r d .
if hasLaidOutCaptionView {
changeBlock ( )
} else {
hasLaidOutCaptionView = true
UIView . performWithoutAnimation { changeBlock ( ) }
}
}
// MARK: - E v e n t H a n d l e r s
@objc
func didTapTouchInterceptorView ( gesture : UITapGestureRecognizer ) {
Logger . info ( " " )
captionView . endEditing ( )
touchInterceptorView . isHidden = true
}
@objc
public func didTapPlayerView ( _ gestureRecognizer : UIGestureRecognizer ) {
assert ( self . videoPlayer != nil )
self . pauseVideo ( )
}
@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 : CaptionViewDelegate {
func captionViewWillBeginEditing ( _ captionView : CaptionView ) {
prepDelegate ? . prepViewController ( self , willBeginEditingCaptionView : captionView )
}
func captionView ( _ captionView : CaptionView , didChangeCaptionText captionText : String ? , attachmentItem : SignalAttachmentItem ) {
let attachment = attachmentItem . attachment
attachment . captionText = captionText
prepDelegate ? . prepViewController ( self , didUpdateCaptionForAttachmentItem : attachmentItem )
}
func captionViewDidBeginEditing ( _ captionView : CaptionView ) {
// D o n ' t a l l o w u s e r t o p a n u n t i l t h e y ' v e d i s m i s s e d t h e k e y b o a r d .
// T h i s a v o i d s a r e a l l y u g l y a n i m a t i o n f r o m s i m u l t a n e o u s l y d i s m i s s i n g t h e k e y b o a r d
// w h i l e l o a d i n g a n e w P r e p V i e w C o n t r o l l e r , a n d i t ' s C a p t i o n V i e w , w h o s e l a y o u t d e p e n d s
// o n t h e k e y b o a r d ' s p o s i t i o n .
touchInterceptorView . isHidden = false
prepDelegate ? . prepViewController ( self , didBeginEditingCaptionView : captionView )
}
func captionViewDidEndEditing ( _ captionView : CaptionView ) {
touchInterceptorView . isHidden = true
prepDelegate ? . prepViewController ( self , didEndEditingCaptionView : captionView )
}
}
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 )
}
}
class BottomToolView : UIView {
let mediaMessageTextToolbar : MediaMessageTextToolbar
let galleryRailView : GalleryRailView
var isEditingMediaMessage : Bool {
return mediaMessageTextToolbar . textView . isFirstResponder
}
let kGalleryRailViewHeight : CGFloat = 72
required init ( isAddMoreVisible : Bool ) {
mediaMessageTextToolbar = MediaMessageTextToolbar ( isAddMoreVisible : isAddMoreVisible )
galleryRailView = GalleryRailView ( )
galleryRailView . scrollFocusMode = . keepWithinBounds
galleryRailView . autoSetDimension ( . height , toSize : kGalleryRailViewHeight )
super . init ( frame : . 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
backgroundColor = UIColor . black . withAlphaComponent ( 0.6 )
preservesSuperviewLayoutMargins = true
let stackView = UIStackView ( arrangedSubviews : [ self . galleryRailView , self . mediaMessageTextToolbar ] )
stackView . axis = . vertical
addSubview ( stackView )
stackView . autoPinEdgesToSuperviewEdges ( )
}
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK:
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
}
}
}
protocol CaptionViewDelegate : class {
func captionView ( _ captionView : CaptionView , didChangeCaptionText captionText : String ? , attachmentItem : SignalAttachmentItem )
func captionViewWillBeginEditing ( _ captionView : CaptionView )
func captionViewDidBeginEditing ( _ captionView : CaptionView )
func captionViewDidEndEditing ( _ captionView : CaptionView )
}
class CaptionView : UIView {
var captionText : String ? {
get { return textView . text }
set {
textView . text = newValue
updatePlaceholderTextViewVisibility ( )
}
}
let attachmentItem : SignalAttachmentItem
var attachment : SignalAttachment {
return attachmentItem . attachment
}
weak var delegate : CaptionViewDelegate ?
private let kMinTextViewHeight : CGFloat = 38
private var textViewHeightConstraint : NSLayoutConstraint !
private lazy var lengthLimitLabel : UILabel = {
let lengthLimitLabel = UILabel ( )
// 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
lengthLimitLabel . isHidden = true
return lengthLimitLabel
} ( )
// MARK: I n i t i a l i z e r s
init ( attachmentItem : SignalAttachmentItem ) {
self . attachmentItem = attachmentItem
super . init ( frame : . zero )
backgroundColor = UIColor . black . withAlphaComponent ( 0.6 )
self . captionText = attachmentItem . captionText
textView . delegate = self
let textContainer = UIView ( )
textContainer . addSubview ( placeholderTextView )
placeholderTextView . autoPinEdgesToSuperviewEdges ( )
textContainer . addSubview ( textView )
textView . autoPinEdgesToSuperviewEdges ( )
textViewHeightConstraint = textView . autoSetDimension ( . height , toSize : kMinTextViewHeight )
let hStack = UIStackView ( arrangedSubviews : [ addCaptionButton , textContainer , doneButton ] )
doneButton . isHidden = true
addSubview ( hStack )
hStack . autoPinEdgesToSuperviewMargins ( )
addSubview ( lengthLimitLabel )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . left )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . right )
lengthLimitLabel . autoPinEdge ( . bottom , to : . top , of : textView , withOffset : - 9 )
lengthLimitLabel . setContentHuggingHigh ( )
lengthLimitLabel . setCompressionResistanceHigh ( )
}
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK:
func endEditing ( ) {
textView . resignFirstResponder ( )
}
override var inputAccessoryView : UIView ? {
// D o n ' t i n h e r i t t h e v c ' s i n p u t A c c e s s o r y V i e w
return nil
}
// MARK: S u b v i e w s
func updatePlaceholderTextViewVisibility ( ) {
let isHidden : Bool = {
guard ! self . textView . isFirstResponder else {
return true
}
guard let captionText = self . textView . text else {
return false
}
guard captionText . count > 0 else {
return false
}
return true
} ( )
placeholderTextView . isHidden = isHidden
}
private lazy var placeholderTextView : UITextView = {
let placeholderTextView = UITextView ( )
placeholderTextView . text = NSLocalizedString ( " ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER " , comment : " placeholder text for an empty captioning field " )
placeholderTextView . isEditable = false
placeholderTextView . backgroundColor = . clear
placeholderTextView . font = UIFont . ows_dynamicTypeBody
placeholderTextView . textColor = Theme . darkThemePrimaryColor
placeholderTextView . tintColor = Theme . darkThemePrimaryColor
placeholderTextView . returnKeyType = . done
return placeholderTextView
} ( )
private lazy var textView : UITextView = {
let textView = UITextView ( )
textView . backgroundColor = . clear
textView . keyboardAppearance = Theme . darkThemeKeyboardAppearance
textView . font = UIFont . ows_dynamicTypeBody
textView . textColor = Theme . darkThemePrimaryColor
textView . tintColor = Theme . darkThemePrimaryColor
return textView
} ( )
lazy var addCaptionButton : UIButton = {
let addCaptionButton = OWSButton { [ weak self ] in
self ? . textView . becomeFirstResponder ( )
}
let icon = # imageLiteral ( resourceName : " ic_add_caption " ) . withRenderingMode ( . alwaysTemplate )
addCaptionButton . setImage ( icon , for : . normal )
addCaptionButton . tintColor = Theme . darkThemePrimaryColor
return addCaptionButton
} ( )
lazy var doneButton : UIButton = {
let doneButton = OWSButton { [ weak self ] in
self ? . textView . resignFirstResponder ( )
}
doneButton . setTitle ( CommonStrings . doneButton , for : . normal )
doneButton . tintColor = Theme . darkThemePrimaryColor
return doneButton
} ( )
}
let kMaxCaptionCharacterCount = 240
extension CaptionView : UITextViewDelegate {
public func textViewShouldBeginEditing ( _ textView : UITextView ) -> Bool {
delegate ? . captionViewWillBeginEditing ( self )
return true
}
public func textViewDidBeginEditing ( _ textView : UITextView ) {
updatePlaceholderTextViewVisibility ( )
doneButton . isHidden = false
addCaptionButton . isHidden = true
delegate ? . captionViewDidBeginEditing ( self )
}
public func textViewDidEndEditing ( _ textView : UITextView ) {
updatePlaceholderTextViewVisibility ( )
doneButton . isHidden = true
addCaptionButton . isHidden = false
delegate ? . captionViewDidEndEditing ( self )
}
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 )
let kMaxCaptionByteCount = kOversizeTextMessageSizeThreshold / 4
guard proposedText . utf8 . count <= kMaxCaptionByteCount else {
Logger . debug ( " hit caption byte count limit " )
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
}
// A f t e r v e r i f y i n g t h e b y t e - l e n g t h i s s u f f i c i e n t l y s m a l l , v e r i f y t h e c h a r a c t e r c o u n t i s w i t h i n b o u n d s .
// N o r m a l l y t h i s c h a r a c t e r c o u n t s h o u l d e n t a i l * m u c h * l e s s b y t e c o u n t .
guard proposedText . count <= kMaxCaptionCharacterCount else {
Logger . debug ( " hit caption character count limit " )
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 .
let charsAfterDelete : Int = ( existingText as NSString ) . replacingCharacters ( in : range , with : " " ) . 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 charBudget : Int = Int ( kMaxCaptionCharacterCount ) - charsAfterDelete
if charBudget >= 0 {
let acceptableNewText = String ( text . prefix ( charBudget ) )
textView . text = ( existingText as NSString ) . replacingCharacters ( in : range , with : acceptableNewText )
}
return false
}
self . lengthLimitLabel . isHidden = true
return true
}
public func textViewDidChange ( _ textView : UITextView ) {
self . delegate ? . captionView ( self , didChangeCaptionText : textView . text , attachmentItem : attachmentItem )
}
}
protocol MediaMessageTextToolbarDelegate : class {
func mediaMessageTextToolbarDidTapSend ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidBeginEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidEndEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidAddMore ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
}
class MediaMessageTextToolbar : UIView , UITextViewDelegate {
weak var mediaMessageTextToolbarDelegate : MediaMessageTextToolbarDelegate ?
var messageText : String ? {
get { return textView . text }
set {
textView . text = newValue
updatePlaceholderTextViewVisibility ( )
}
}
// 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
// MARK: - I n i t i a l i z e r s
init ( isAddMoreVisible : Bool ) {
self . addMoreButton = UIButton ( type : . custom )
self . sendButton = UIButton ( type : . system )
self . textViewHeight = kMinTextViewHeight
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
let addMoreIcon = # imageLiteral ( resourceName : " album_add_more " ) . withRenderingMode ( . alwaysTemplate )
addMoreButton . setImage ( addMoreIcon , for : . normal )
addMoreButton . tintColor = Theme . darkThemePrimaryColor
addMoreButton . addTarget ( self , action : #selector ( didTapAddMore ) , for : . touchUpInside )
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 = Theme . galleryHighlightColor
// 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 )
let contentView = UIView ( )
contentView . addSubview ( sendButton )
contentView . addSubview ( textContainer )
contentView . addSubview ( lengthLimitLabel )
if isAddMoreVisible {
contentView . addSubview ( addMoreButton )
}
addSubview ( contentView )
contentView . autoPinEdgesToSuperviewEdges ( )
// 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
textContainer . autoPinEdge ( toSuperviewMargin : . top )
textContainer . autoPinEdge ( toSuperviewMargin : . bottom )
if isAddMoreVisible {
addMoreButton . autoPinEdge ( toSuperviewMargin : . left )
textContainer . autoPinEdge ( . left , to : . right , of : addMoreButton , withOffset : kToolbarMargin )
addMoreButton . autoAlignAxis ( . horizontal , toSameAxisOf : sendButton )
addMoreButton . setContentHuggingHigh ( )
addMoreButton . setCompressionResistanceHigh ( )
} else {
textContainer . autoPinEdge ( toSuperviewMargin : . left )
}
sendButton . autoPinEdge ( . left , to : . right , of : textContainer , withOffset : kToolbarMargin )
sendButton . autoPinEdge ( . bottom , to : . bottom , of : textContainer , withOffset : - 3 )
sendButton . autoPinEdge ( toSuperviewMargin : . right )
sendButton . setContentHuggingHigh ( )
sendButton . setCompressionResistanceHigh ( )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . left )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . right )
lengthLimitLabel . autoPinEdge ( . bottom , to : . top , of : textContainer , withOffset : - 6 )
lengthLimitLabel . setContentHuggingHigh ( )
lengthLimitLabel . setCompressionResistanceHigh ( )
}
required init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
// MARK: - U I V i e w O v e r r i d e s
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: - S u b v i e w s
private let addMoreButton : UIButton
private let sendButton : UIButton
private lazy var lengthLimitLabel : UILabel = {
let lengthLimitLabel = UILabel ( )
// 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_MESSAGE_LENGTH_LIMIT_REACHED " , comment : " One-line label indicating the user can add no more text to the media message field. " )
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
lengthLimitLabel . isHidden = true
return lengthLimitLabel
} ( )
lazy var textView : UITextView = {
let textView = buildTextView ( )
textView . returnKeyType = . done
textView . scrollIndicatorInsets = UIEdgeInsets ( top : 5 , left : 0 , bottom : 5 , right : 3 )
return textView
} ( )
private lazy var placeholderTextView : UITextView = {
let placeholderTextView = buildTextView ( )
placeholderTextView . text = NSLocalizedString ( " MESSAGE_TEXT_FIELD_PLACEHOLDER " , comment : " placeholder text for the editable message field " )
placeholderTextView . isEditable = false
return placeholderTextView
} ( )
private lazy var textContainer : UIView = {
let textContainer = UIView ( )
textContainer . layer . borderColor = Theme . darkThemePrimaryColor . cgColor
textContainer . layer . borderWidth = 0.5
textContainer . layer . cornerRadius = kMinTextViewHeight / 2
textContainer . clipsToBounds = true
textContainer . addSubview ( placeholderTextView )
placeholderTextView . autoPinEdgesToSuperviewEdges ( )
textContainer . addSubview ( textView )
textView . autoPinEdgesToSuperviewEdges ( )
return textContainer
} ( )
private func buildTextView ( ) -> UITextView {
let textView = MessageTextView ( )
textView . keyboardAppearance = Theme . darkThemeKeyboardAppearance
textView . backgroundColor = . clear
textView . tintColor = Theme . darkThemePrimaryColor
textView . font = UIFont . ows_dynamicTypeBody
textView . textColor = Theme . darkThemePrimaryColor
textView . textContainerInset = UIEdgeInsets ( top : 7 , left : 7 , bottom : 7 , right : 7 )
return textView
}
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
// 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 )
}
}
// MARK: - A c t i o n s
@objc func didTapSend ( ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidTapSend ( self )
}
@objc func didTapAddMore ( ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidAddMore ( 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 )
}
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 ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidBeginEditing ( self )
updatePlaceholderTextViewVisibility ( )
}
public func textViewDidEndEditing ( _ textView : UITextView ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidEndEditing ( self )
updatePlaceholderTextViewVisibility ( )
}
// MARK: - H e l p e r s
func updatePlaceholderTextViewVisibility ( ) {
let isHidden : Bool = {
guard ! self . textView . isFirstResponder else {
return true
}
guard let captionText = self . textView . text else {
return false
}
guard captionText . count > 0 else {
return false
}
return true
} ( )
placeholderTextView . isHidden = isHidden
}
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 != textViewHeight {
Logger . debug ( " TextView height changed: \( textViewHeight ) -> \( newHeight ) " )
textViewHeight = newHeight
textViewHeightConstraint ? . constant = textViewHeight
invalidateIntrinsicContentSize ( )
}
}
private func clampedTextViewHeight ( fixedWidth : CGFloat ) -> CGFloat {
let contentSize = textView . sizeThatFits ( CGSize ( width : fixedWidth , height : CGFloat . greatestFiniteMagnitude ) )
return CGFloatClamp ( contentSize . height , kMinTextViewHeight , maxTextViewHeight )
}
}