2022-09-30 13:28:09 +02:00
//
// C o m p o s e C o n t e n t V i e w C o n t r o l l e r . s w i f t
//
//
// C r e a t e d b y M a i n a s u K o n 2 2 / 9 / 3 0 .
//
import os . log
import UIKit
2022-10-10 13:14:52 +02:00
import SwiftUI
2022-10-11 12:31:40 +02:00
import Combine
2022-10-21 13:12:44 +02:00
import PhotosUI
2022-10-18 13:01:31 +02:00
import MastodonCore
2022-09-30 13:28:09 +02:00
public final class ComposeContentViewController : UIViewController {
let logger = Logger ( subsystem : " ComposeContentViewController " , category : " ViewController " )
2022-10-11 12:31:40 +02:00
var disposeBag = Set < AnyCancellable > ( )
2022-10-10 13:14:52 +02:00
public var viewModel : ComposeContentViewModel !
2022-10-21 13:12:44 +02:00
private ( set ) lazy var composeContentToolbarViewModel = ComposeContentToolbarView . ViewModel ( delegate : self )
2022-09-30 13:28:09 +02:00
2022-10-10 13:14:52 +02:00
let tableView : ComposeTableView = {
let tableView = ComposeTableView ( )
2022-10-11 12:31:40 +02:00
tableView . estimatedRowHeight = UITableView . automaticDimension
2022-10-10 13:14:52 +02:00
tableView . alwaysBounceVertical = true
tableView . separatorStyle = . none
tableView . tableFooterView = UIView ( )
return tableView
} ( )
2022-10-18 13:01:31 +02:00
lazy var composeContentToolbarView = ComposeContentToolbarView ( viewModel : composeContentToolbarViewModel )
var composeContentToolbarViewBottomLayoutConstraint : NSLayoutConstraint !
let composeContentToolbarBackgroundView = UIView ( )
2022-10-21 13:12:44 +02:00
// m e d i a p i c k e r
static func createPhotoLibraryPickerConfiguration ( selectionLimit : Int = 4 ) -> PHPickerConfiguration {
var configuration = PHPickerConfiguration ( )
configuration . filter = . any ( of : [ . images , . videos ] )
configuration . selectionLimit = selectionLimit
return configuration
}
private ( set ) lazy var photoLibraryPicker : PHPickerViewController = {
let imagePicker = PHPickerViewController ( configuration : ComposeContentViewController . createPhotoLibraryPickerConfiguration ( ) )
imagePicker . delegate = self
return imagePicker
} ( )
private ( set ) lazy var imagePickerController : UIImagePickerController = {
let imagePickerController = UIImagePickerController ( )
imagePickerController . sourceType = . camera
imagePickerController . delegate = self
return imagePickerController
} ( )
private ( set ) lazy var documentPickerController : UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController ( forOpeningContentTypes : [ . image , . movie ] )
documentPickerController . delegate = self
return documentPickerController
} ( )
2022-10-10 13:14:52 +02:00
deinit {
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
}
2022-09-30 13:28:09 +02:00
}
extension ComposeContentViewController {
public override func viewDidLoad ( ) {
super . viewDidLoad ( )
2022-10-18 13:01:31 +02:00
// s e t u p v i e w
self . setupBackgroundColor ( theme : ThemeService . shared . currentTheme . value )
ThemeService . shared . currentTheme
. receive ( on : RunLoop . main )
. sink { [ weak self ] theme in
guard let self = self else { return }
self . setupBackgroundColor ( theme : theme )
}
. store ( in : & disposeBag )
// s e t u p t a b l e V i e w
2022-10-10 13:14:52 +02:00
tableView . translatesAutoresizingMaskIntoConstraints = false
view . addSubview ( tableView )
NSLayoutConstraint . activate ( [
tableView . topAnchor . constraint ( equalTo : view . topAnchor ) ,
tableView . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
tableView . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
tableView . bottomAnchor . constraint ( equalTo : view . bottomAnchor ) ,
] )
tableView . delegate = self
viewModel . setupDataSource ( tableView : tableView )
2022-10-18 13:01:31 +02:00
let toolbarHostingView = UIHostingController ( rootView : composeContentToolbarView )
toolbarHostingView . view . translatesAutoresizingMaskIntoConstraints = false
view . addSubview ( toolbarHostingView . view )
composeContentToolbarViewBottomLayoutConstraint = view . bottomAnchor . constraint ( equalTo : toolbarHostingView . view . bottomAnchor )
NSLayoutConstraint . activate ( [
toolbarHostingView . view . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
toolbarHostingView . view . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
composeContentToolbarViewBottomLayoutConstraint ,
toolbarHostingView . view . heightAnchor . constraint ( equalToConstant : ComposeContentToolbarView . toolbarHeight ) ,
] )
toolbarHostingView . view . preservesSuperviewLayoutMargins = true
// c o m p o s e T o o l b a r V i e w . d e l e g a t e = s e l f
composeContentToolbarBackgroundView . translatesAutoresizingMaskIntoConstraints = false
view . insertSubview ( composeContentToolbarBackgroundView , belowSubview : toolbarHostingView . view )
NSLayoutConstraint . activate ( [
composeContentToolbarBackgroundView . topAnchor . constraint ( equalTo : toolbarHostingView . view . topAnchor ) ,
composeContentToolbarBackgroundView . leadingAnchor . constraint ( equalTo : toolbarHostingView . view . leadingAnchor ) ,
composeContentToolbarBackgroundView . trailingAnchor . constraint ( equalTo : toolbarHostingView . view . trailingAnchor ) ,
view . bottomAnchor . constraint ( equalTo : composeContentToolbarBackgroundView . bottomAnchor ) ,
] )
let keyboardHasShortcutBar = CurrentValueSubject < Bool , Never > ( traitCollection . userInterfaceIdiom = = . pad ) // u p d a t e d e f a u l t v a l u e l a t e r
let keyboardEventPublishers = Publishers . CombineLatest3 (
KeyboardResponderService . shared . isShow ,
KeyboardResponderService . shared . state ,
KeyboardResponderService . shared . endFrame
)
// P u b l i s h e r s . C o m b i n e L a t e s t 3 (
// v i e w M o d e l . $ i s C u s t o m E m o j i C o m p o s i n g ,
// )
keyboardEventPublishers
. sink ( receiveValue : { [ weak self ] keyboardEvents in
guard let self = self else { return }
let ( isShow , state , endFrame ) = keyboardEvents
// s w i t c h s e l f . t r a i t C o l l e c t i o n . u s e r I n t e r f a c e I d i o m {
// c a s e . p a d :
// k e y b o a r d H a s S h o r t c u t B a r . v a l u e = s t a t e ! = . f l o a t i n g
// d e f a u l t :
// k e y b o a r d H a s S h o r t c u t B a r . v a l u e = f a l s e
// }
//
let extraMargin : CGFloat = {
var margin = ComposeContentToolbarView . toolbarHeight
// i f a u t o C o m p l e t e I n f o ! = n i l {
// / / m a r g i n + = C o m p o s e V i e w C o n t r o l l e r . m i n A u t o C o m p l e t e V i s i b l e H e i g h t
// }
return margin
} ( )
//
guard isShow , state = = . dock else {
self . tableView . contentInset . bottom = extraMargin
self . tableView . verticalScrollIndicatorInsets . bottom = extraMargin
// i f l e t s u p e r V i e w = s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . s u p e r v i e w {
// l e t a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t : C G F l o a t = {
// l e t t a b l e V i e w F r a m e I n W i n d o w = s u p e r V i e w . c o n v e r t ( s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . f r a m e , t o : n i l )
// l e t p a d d i n g = t a b l e V i e w F r a m e I n W i n d o w . m a x Y + s e l f . c o m p o s e T o o l b a r V i e w . f r a m e . h e i g h t + A u t o C o m p l e t e V i e w C o n t r o l l e r . c h e v r o n V i e w H e i g h t - s e l f . v i e w . f r a m e . m a x Y
// r e t u r n m a x ( 0 , p a d d i n g )
// } ( )
// s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . c o n t e n t I n s e t . b o t t o m = a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t
// s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . v e r t i c a l S c r o l l I n d i c a t o r I n s e t s . b o t t o m = a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t
// }
UIView . animate ( withDuration : 0.3 ) {
self . composeContentToolbarViewBottomLayoutConstraint . constant = self . view . safeAreaInsets . bottom
if self . view . window != nil {
self . view . layoutIfNeeded ( )
}
}
return
}
// i s S h o w A N D d o c k s t a t e
// s e l f . s y s t e m K e y b o a r d H e i g h t = e n d F r a m e . h e i g h t
// a d j u s t i n s e t f o r a u t o - c o m p l e t e
// l e t a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t : C G F l o a t = {
// g u a r d l e t s u p e r v i e w = s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . s u p e r v i e w e l s e { r e t u r n . z e r o }
// l e t t a b l e V i e w F r a m e I n W i n d o w = s u p e r v i e w . c o n v e r t ( s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . f r a m e , t o : n i l )
// l e t p a d d i n g = t a b l e V i e w F r a m e I n W i n d o w . m a x Y + s e l f . c o m p o s e T o o l b a r V i e w . f r a m e . h e i g h t + A u t o C o m p l e t e V i e w C o n t r o l l e r . c h e v r o n V i e w H e i g h t - e n d F r a m e . m i n Y
// r e t u r n m a x ( 0 , p a d d i n g )
// } ( )
// s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . c o n t e n t I n s e t . b o t t o m = a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t
// s e l f . a u t o C o m p l e t e V i e w C o n t r o l l e r . t a b l e V i e w . v e r t i c a l S c r o l l I n d i c a t o r I n s e t s . b o t t o m = a u t o C o m p l e t e T a b l e V i e w B o t t o m I n s e t
// a d j u s t i n s e t f o r t a b l e V i e w
let contentFrame = self . view . convert ( self . tableView . frame , to : nil )
let padding = contentFrame . maxY + extraMargin - endFrame . minY
guard padding > 0 else {
self . tableView . contentInset . bottom = self . view . safeAreaInsets . bottom + extraMargin
self . tableView . verticalScrollIndicatorInsets . bottom = self . view . safeAreaInsets . bottom + extraMargin
return
}
self . tableView . contentInset . bottom = padding - self . view . safeAreaInsets . bottom
self . tableView . verticalScrollIndicatorInsets . bottom = padding - self . view . safeAreaInsets . bottom
UIView . animate ( withDuration : 0.3 ) {
self . composeContentToolbarViewBottomLayoutConstraint . constant = endFrame . height
self . view . layoutIfNeeded ( )
}
} )
. store ( in : & disposeBag )
2022-10-11 12:31:40 +02:00
// s e t u p s n a p b e h a v i o r
Publishers . CombineLatest (
viewModel . $ replyToCellFrame ,
viewModel . $ scrollViewState
)
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] replyToCellFrame , scrollViewState in
guard let self = self else { return }
guard replyToCellFrame != . zero else { return }
switch scrollViewState {
case . fold :
self . tableView . contentInset . top = - replyToCellFrame . height
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: set contentInset.top: -%s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , replyToCellFrame . height . description )
case . expand :
self . tableView . contentInset . top = 0
}
}
. store ( in : & disposeBag )
2022-10-21 13:12:44 +02:00
// b i n d t o o l b a r
bindToolbarViewModel ( )
2022-10-11 12:31:40 +02:00
}
public override func viewDidLayoutSubviews ( ) {
super . viewDidLayoutSubviews ( )
viewModel . viewLayoutFrame . update ( view : view )
}
public override func viewSafeAreaInsetsDidChange ( ) {
super . viewSafeAreaInsetsDidChange ( )
viewModel . viewLayoutFrame . update ( view : view )
}
public override func viewWillTransition ( to size : CGSize , with coordinator : UIViewControllerTransitionCoordinator ) {
super . viewWillTransition ( to : size , with : coordinator )
coordinator . animate { [ weak self ] coordinatorContext in
guard let self = self else { return }
self . viewModel . viewLayoutFrame . update ( view : self . view )
}
}
}
2022-10-18 13:01:31 +02:00
extension ComposeContentViewController {
private func setupBackgroundColor ( theme : Theme ) {
let backgroundColor = UIColor ( dynamicProvider : { traitCollection in
switch traitCollection . userInterfaceStyle {
case . light : return . systemBackground
default : return theme . systemElevatedBackgroundColor
}
} )
view . backgroundColor = backgroundColor
tableView . backgroundColor = backgroundColor
composeContentToolbarBackgroundView . backgroundColor = theme . composeToolbarBackgroundColor
}
2022-10-21 13:12:44 +02:00
private func bindToolbarViewModel ( ) {
viewModel . $ isPollActive . assign ( to : & composeContentToolbarViewModel . $ isPollActive )
viewModel . $ isEmojiActive . assign ( to : & composeContentToolbarViewModel . $ isEmojiActive )
viewModel . $ isContentWarningActive . assign ( to : & composeContentToolbarViewModel . $ isContentWarningActive )
2022-10-28 13:06:18 +02:00
viewModel . $ maxTextInputLimit . assign ( to : & composeContentToolbarViewModel . $ maxTextInputLimit )
viewModel . $ contentWeightedLength . assign ( to : & composeContentToolbarViewModel . $ contentWeightedLength )
viewModel . $ contentWarningWeightedLength . assign ( to : & composeContentToolbarViewModel . $ contentWarningWeightedLength )
2022-10-21 13:12:44 +02:00
}
2022-10-18 13:01:31 +02:00
}
2022-10-11 12:31:40 +02:00
// MARK: - U I S c r o l l V i e w D e l e g a t e
extension ComposeContentViewController {
public func scrollViewWillEndDragging ( _ scrollView : UIScrollView , withVelocity velocity : CGPoint , targetContentOffset : UnsafeMutablePointer < CGPoint > ) {
guard scrollView = = = tableView else { return }
let replyToCellFrame = viewModel . replyToCellFrame
guard replyToCellFrame != . zero else { return }
// t r y t o f i n d s o m e p a t t e r n s :
// p r i n t ( " " "
// r e p l i e d T o C e l l F r a m e : \ ( v i e w M o d e l . r e p l i e d T o C e l l F r a m e . v a l u e . h e i g h t )
// s c r o l l V i e w . c o n t e n t O f f s e t . y : \ ( s c r o l l V i e w . c o n t e n t O f f s e t . y )
// s c r o l l V i e w . c o n t e n t S i z e . h e i g h t : \ ( s c r o l l V i e w . c o n t e n t S i z e . h e i g h t )
// s c r o l l V i e w . f r a m e : \ ( s c r o l l V i e w . f r a m e )
// s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . t o p : \ ( s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . t o p )
// s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . b o t t o m : \ ( s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . b o t t o m )
// " " " )
switch viewModel . scrollViewState {
case . fold :
logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : fold " )
guard velocity . y < 0 else { return }
let offsetY = scrollView . contentOffset . y + scrollView . adjustedContentInset . top
if offsetY < - 44 {
tableView . contentInset . top = 0
targetContentOffset . pointee = CGPoint ( x : 0 , y : - scrollView . adjustedContentInset . top )
viewModel . scrollViewState = . expand
}
case . expand :
logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : expand " )
guard velocity . y > 0 else { return }
// c h e c k i f t o p a c r o s s
let topOffset = ( scrollView . contentOffset . y + scrollView . adjustedContentInset . top ) - replyToCellFrame . height
// c h e c k i f b o t t o m b o u n c e
let bottomOffsetY = scrollView . contentOffset . y + ( scrollView . frame . height - scrollView . adjustedContentInset . bottom )
let bottomOffset = bottomOffsetY - scrollView . contentSize . height
if topOffset > 44 {
// d o n o t i n t e r r u p t u s e r s c r o l l i n g
viewModel . scrollViewState = . fold
} else if bottomOffset > 44 {
tableView . contentInset . top = - replyToCellFrame . height
targetContentOffset . pointee = CGPoint ( x : 0 , y : - replyToCellFrame . height )
viewModel . scrollViewState = . fold
}
}
2022-09-30 13:28:09 +02:00
}
}
2022-10-10 13:14:52 +02:00
// MARK: - U I T a b l e V i e w D e l e g a t e
extension ComposeContentViewController : UITableViewDelegate { }
2022-10-21 13:12:44 +02:00
// MARK: - P H P i c k e r V i e w C o n t r o l l e r D e l e g a t e
extension ComposeContentViewController : PHPickerViewControllerDelegate {
public func picker ( _ picker : PHPickerViewController , didFinishPicking results : [ PHPickerResult ] ) {
picker . dismiss ( animated : true , completion : nil )
// TODO:
// l e t a t t a c h m e n t S e r v i c e s : [ M a s t o d o n A t t a c h m e n t S e r v i c e ] = r e s u l t s . m a p { r e s u l t i n
// l e t s e r v i c e = M a s t o d o n A t t a c h m e n t S e r v i c e (
// c o n t e x t : c o n t e x t ,
// p i c k e r R e s u l t : r e s u l t ,
// i n i t i a l A u t h e n t i c a t i o n B o x : v i e w M o d e l . a u t h e n t i c a t i o n B o x
// )
// r e t u r n s e r v i c e
// }
// v i e w M o d e l . a t t a c h m e n t S e r v i c e s = v i e w M o d e l . a t t a c h m e n t S e r v i c e s + a t t a c h m e n t S e r v i c e s
}
}
// MARK: - U I I m a g e P i c k e r C o n t r o l l e r D e l e g a t e
extension ComposeContentViewController : UIImagePickerControllerDelegate & UINavigationControllerDelegate {
public func imagePickerController ( _ picker : UIImagePickerController , didFinishPickingMediaWithInfo info : [ UIImagePickerController . InfoKey : Any ] ) {
picker . dismiss ( animated : true , completion : nil )
guard let image = info [ . originalImage ] as ? UIImage else { return }
// l e t a t t a c h m e n t S e r v i c e = M a s t o d o n A t t a c h m e n t S e r v i c e (
// c o n t e x t : c o n t e x t ,
// i m a g e : i m a g e ,
// i n i t i a l A u t h e n t i c a t i o n B o x : v i e w M o d e l . a u t h e n t i c a t i o n B o x
// )
// v i e w M o d e l . a t t a c h m e n t S e r v i c e s = v i e w M o d e l . a t t a c h m e n t S e r v i c e s + [ a t t a c h m e n t S e r v i c e ]
}
public func imagePickerControllerDidCancel ( _ picker : UIImagePickerController ) {
os_log ( " %{public}s[%{public}ld], %{public}s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
picker . dismiss ( animated : true , completion : nil )
}
}
// MARK: - U I D o c u m e n t P i c k e r D e l e g a t e
extension ComposeContentViewController : UIDocumentPickerDelegate {
public func documentPicker ( _ controller : UIDocumentPickerViewController , didPickDocumentsAt urls : [ URL ] ) {
guard let url = urls . first else { return }
// l e t a t t a c h m e n t S e r v i c e = M a s t o d o n A t t a c h m e n t S e r v i c e (
// c o n t e x t : c o n t e x t ,
// d o c u m e n t U R L : u r l ,
// i n i t i a l A u t h e n t i c a t i o n B o x : v i e w M o d e l . a u t h e n t i c a t i o n B o x
// )
// v i e w M o d e l . a t t a c h m e n t S e r v i c e s = v i e w M o d e l . a t t a c h m e n t S e r v i c e s + [ a t t a c h m e n t S e r v i c e ]
}
}
// MARK: - C o m p o s e C o n t e n t T o o l b a r V i e w D e l e g a t e
extension ComposeContentViewController : ComposeContentToolbarViewDelegate {
func composeContentToolbarView (
_ viewModel : ComposeContentToolbarView . ViewModel ,
toolbarItemDidPressed action : ComposeContentToolbarView . ViewModel . Action
) {
switch action {
case . attachment :
assertionFailure ( )
case . poll :
self . viewModel . isPollActive . toggle ( )
case . emoji :
self . viewModel . isEmojiActive . toggle ( )
case . contentWarning :
self . viewModel . isContentWarningActive . toggle ( )
2022-10-28 13:06:18 +02:00
if self . viewModel . isContentWarningActive {
Task { @ MainActor in
try ? await Task . sleep ( nanoseconds : . second / 20 ) // 0 . 0 5 s
self . viewModel . setContentWarningTextViewFirstResponderIfNeeds ( )
} // e n d T a s k
} else {
if self . viewModel . contentWarningMetaText ? . textView . isFirstResponder = = true {
self . viewModel . setContentTextViewFirstResponderIfNeeds ( )
}
}
2022-10-21 13:12:44 +02:00
case . visibility :
assertionFailure ( )
}
}
func composeContentToolbarView (
_ viewModel : ComposeContentToolbarView . ViewModel ,
attachmentMenuDidPressed action : ComposeContentToolbarView . ViewModel . AttachmentAction
) {
switch action {
case . photoLibrary :
present ( photoLibraryPicker , animated : true , completion : nil )
case . camera :
present ( imagePickerController , animated : true , completion : nil )
case . browse :
#if SNAPSHOT
guard let image = UIImage ( named : " Athens " ) else { return }
let attachmentService = MastodonAttachmentService (
context : context ,
image : image ,
initialAuthenticationBox : viewModel . authenticationBox
)
viewModel . attachmentServices = viewModel . attachmentServices + [ attachmentService ]
#else
present ( documentPickerController , animated : true , completion : nil )
#endif
}
}
}