InputBarAccessoryView.swift 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  1. //
  2. // InputBarAccessoryView.swift
  3. // InputBarAccessoryView
  4. //
  5. // Copyright © 2017-2020 Nathan Tannar.
  6. //
  7. // Permission is hereby granted, free of charge, to any person obtaining a copy
  8. // of this software and associated documentation files (the "Software"), to deal
  9. // in the Software without restriction, including without limitation the rights
  10. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. // copies of the Software, and to permit persons to whom the Software is
  12. // furnished to do so, subject to the following conditions:
  13. //
  14. // The above copyright notice and this permission notice shall be included in all
  15. // copies or substantial portions of the Software.
  16. //
  17. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23. // SOFTWARE.
  24. //
  25. // Created by Nathan Tannar on 8/18/17.
  26. //
  27. import UIKit
  28. import DcCore
  29. /// A powerful InputAccessoryView ideal for messaging applications
  30. open class InputBarAccessoryView: UIView {
  31. // MARK: - Properties
  32. /// A delegate to broadcast notifications from the `InputBarAccessoryView`
  33. open weak var delegate: InputBarAccessoryViewDelegate?
  34. /// The background UIView anchored to the bottom, left, and right of the InputBarAccessoryView
  35. /// with a top anchor equal to the bottom of the top InputStackView
  36. open var backgroundView: UIView = {
  37. let view = UIView()
  38. view.translatesAutoresizingMaskIntoConstraints = false
  39. view.backgroundColor = InputBarAccessoryView.defaultBackgroundColor
  40. return view
  41. }()
  42. /// A content UIView that holds the left/right/bottom InputStackViews
  43. /// and the middleContentView. Anchored to the bottom of the
  44. /// topStackView and inset by the padding UIEdgeInsets
  45. open var contentView: UIView = {
  46. let view = UIView()
  47. view.translatesAutoresizingMaskIntoConstraints = false
  48. return view
  49. }()
  50. /**
  51. A UIVisualEffectView that adds a blur effect to make the view appear transparent.
  52. */
  53. open lazy var blurView: UIVisualEffectView = {
  54. var blurEffect = UIBlurEffect(style: .light)
  55. if #available(iOS 13, *) {
  56. blurEffect = UIBlurEffect(style: .systemMaterial)
  57. }
  58. let view = UIVisualEffectView(effect: blurEffect)
  59. view.translatesAutoresizingMaskIntoConstraints = false
  60. return view
  61. }()
  62. /// Determines if the InputBarAccessoryView should have a translucent effect
  63. open var isTranslucent: Bool = true {
  64. didSet {
  65. blurView.isHidden = !isTranslucent
  66. }
  67. }
  68. /// A SeparatorLine that is anchored at the top of the InputBarAccessoryView
  69. public let separatorLine = SeparatorLine()
  70. /**
  71. The InputStackView at the InputStackView.top position
  72. ## Important Notes ##
  73. 1. It's axis is initially set to .vertical
  74. 2. It's alignment is initially set to .fill
  75. */
  76. public let topStackView: InputStackView = {
  77. let stackView = InputStackView(axis: .vertical, spacing: 0)
  78. stackView.alignment = .fill
  79. return stackView
  80. }()
  81. /**
  82. The InputStackView at the InputStackView.left position
  83. ## Important Notes ##
  84. 1. It's axis is initially set to .horizontal
  85. */
  86. public let leftStackView = InputStackView(axis: .horizontal, spacing: 0)
  87. /**
  88. The InputStackView at the InputStackView.right position
  89. ## Important Notes ##
  90. 1. It's axis is initially set to .horizontal
  91. */
  92. public let rightStackView = InputStackView(axis: .horizontal, spacing: 0)
  93. /**
  94. The InputStackView at the InputStackView.bottom position
  95. ## Important Notes ##
  96. 1. It's axis is initially set to .horizontal
  97. 2. It's spacing is initially set to 15
  98. */
  99. public let bottomStackView = InputStackView(axis: .horizontal, spacing: 15)
  100. /**
  101. The main view component of the InputBarAccessoryView
  102. The default value is the `InputTextView`.
  103. ## Important Notes ##
  104. 1. This view should self-size with constraints or an
  105. intrinsicContentSize to auto-size the InputBarAccessoryView
  106. 2. Override with `setMiddleContentView(view: UIView?, animated: Bool)`
  107. */
  108. public private(set) weak var middleContentView: UIView?
  109. /// A view to wrap the `middleContentView` inside
  110. private let middleContentViewWrapper: UIView = {
  111. let view = UIView()
  112. view.translatesAutoresizingMaskIntoConstraints = false
  113. return view
  114. }()
  115. private static let defaultBackgroundColor: UIColor = {
  116. if #available(iOS 13, *) {
  117. return .systemBackground
  118. } else {
  119. return .white
  120. }
  121. }()
  122. /// The InputTextView a user can input a message in
  123. open lazy var inputTextView: ChatInputTextView = {
  124. let inputTextView = ChatInputTextView()
  125. inputTextView.translatesAutoresizingMaskIntoConstraints = false
  126. inputTextView.inputBarAccessoryView = self
  127. return inputTextView
  128. }()
  129. /// A InputBarButtonItem used as the send button and initially placed in the rightStackView
  130. open var sendButton: InputBarSendButton = {
  131. return InputBarSendButton()
  132. .configure {
  133. $0.setSize(CGSize(width: 52, height: 36), animated: false)
  134. $0.isEnabled = false
  135. $0.title = "Send"
  136. $0.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .bold)
  137. }.onTouchUpInside {
  138. $0.inputBarAccessoryView?.didSelectSendButton()
  139. }
  140. }()
  141. /**
  142. The anchor contants used to add horizontal inset from the InputBarAccessoryView and the
  143. window. By default, an `inputAccessoryView` spans the entire width of the UIWindow. You
  144. can manage these insets if you wish to implement designs that do not have the bar spanning
  145. the entire width.
  146. ## Important Notes ##
  147. USE AT YOUR OWN RISK
  148. ````
  149. H:|-(frameInsets.left)-[InputBarAccessoryView]-(frameInsets.right)-|
  150. ````
  151. */
  152. open var frameInsets: HorizontalEdgePadding = .zero {
  153. didSet {
  154. updateFrameInsets()
  155. }
  156. }
  157. /**
  158. The anchor constants used by the InputStackView's and InputTextView to create padding
  159. within the InputBarAccessoryView
  160. ## Important Notes ##
  161. ````
  162. V:|...[InputStackView.top]-(padding.top)-[contentView]-(padding.bottom)-|
  163. H:|-(frameInsets.left)-(padding.left)-[contentView]-(padding.right)-(frameInsets.right)-|
  164. ````
  165. */
  166. open var padding: UIEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) {
  167. didSet {
  168. updatePadding()
  169. }
  170. }
  171. /**
  172. The anchor constants used by the top InputStackView
  173. ## Important Notes ##
  174. 1. The topStackViewPadding.bottom property is not used. Use padding.top
  175. ````
  176. V:|-(topStackViewPadding.top)-[InputStackView.top]-(padding.top)-[middleContentView]-...|
  177. H:|-(frameInsets.left)-(topStackViewPadding.left)-[InputStackView.top]-(topStackViewPadding.right)-(frameInsets.right)-|
  178. ````
  179. */
  180. open var topStackViewPadding: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) {
  181. didSet {
  182. updateTopStackViewPadding()
  183. }
  184. }
  185. /**
  186. The anchor constants used by the middleContentView
  187. ````
  188. V:|...-(padding.top)-(middleContentViewPadding.top)-[middleContentView]-(middleContentViewPadding.bottom)-[InputStackView.bottom]-...|
  189. H:|...-[InputStackView.left]-(middleContentViewPadding.left)-[middleContentView]-(middleContentViewPadding.right)-[InputStackView.right]-...|
  190. ````
  191. */
  192. open var middleContentViewPadding: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) {
  193. didSet {
  194. updateMiddleContentViewPadding()
  195. }
  196. }
  197. /// Returns the most recent size calculated by `calculateIntrinsicContentSize()`
  198. open override var intrinsicContentSize: CGSize {
  199. return cachedIntrinsicContentSize
  200. }
  201. /// The intrinsicContentSize can change a lot so the delegate method
  202. /// `inputBar(self, didChangeIntrinsicContentTo: size)` only needs to be called
  203. /// when it's different
  204. public private(set) var previousIntrinsicContentSize: CGSize?
  205. /// The most recent calculation of the intrinsicContentSize
  206. private lazy var cachedIntrinsicContentSize: CGSize = calculateIntrinsicContentSize()
  207. /// A boolean that indicates if the maxTextViewHeight has been met. Keeping track of this
  208. /// improves the performance
  209. /// The default value is `FALSE`
  210. public private(set) var isOverMaxTextViewHeight = false
  211. /// A boolean that when set as `TRUE` will always enable the `InputTextView` to be anchored to the
  212. /// height of `maxTextViewHeight`
  213. /// The default value is `FALSE`
  214. public private(set) var shouldForceTextViewMaxHeight = false
  215. /// A boolean that determines if the `maxTextViewHeight` should be maintained automatically.
  216. /// To control the maximum height of the view yourself, set this to `false`.
  217. /// The default value is `TRUE`
  218. open var shouldAutoUpdateMaxTextViewHeight = true
  219. /// The maximum height that the InputTextView can reach.
  220. /// This is set automatically when `shouldAutoUpdateMaxTextViewHeight` is true.
  221. /// To control the height yourself, make sure to set `shouldAutoUpdateMaxTextViewHeight` to false.
  222. /// The default value is `0`
  223. open var maxTextViewHeight: CGFloat = 0 {
  224. didSet {
  225. textViewHeightAnchor?.constant = maxTextViewHeight
  226. }
  227. }
  228. /// A boolean that determines whether the sendButton's `isEnabled` state should be managed automatically.
  229. /// The default value is `TRUE`
  230. open var shouldManageSendButtonEnabledState = true
  231. /// A boolean that determines if the layout required for new or typed text should
  232. /// be animated.
  233. /// The default value is `FALSE`
  234. open var shouldAnimateTextDidChangeLayout = false
  235. /// The height that will fit the current text in the InputTextView based on its current bounds
  236. public var requiredInputTextViewHeight: CGFloat {
  237. guard middleContentView == inputTextView else {
  238. return middleContentView?.intrinsicContentSize.height ?? 0
  239. }
  240. let maxTextViewSize = CGSize(width: inputTextView.bounds.width, height: .greatestFiniteMagnitude)
  241. return inputTextView.sizeThatFits(maxTextViewSize).height.rounded(.down)
  242. }
  243. /// The fixed widthAnchor constant of the leftStackView
  244. /// The default value is `0`
  245. public private(set) var leftStackViewWidthConstant: CGFloat = 0 {
  246. didSet {
  247. leftStackViewLayoutSet?.width?.constant = leftStackViewWidthConstant
  248. }
  249. }
  250. /// The fixed widthAnchor constant of the rightStackView
  251. /// The default value is `52`
  252. public private(set) var rightStackViewWidthConstant: CGFloat = 52 {
  253. didSet {
  254. rightStackViewLayoutSet?.width?.constant = rightStackViewWidthConstant
  255. }
  256. }
  257. /// Holds the InputPlugin plugins that can be used to extend the functionality of the InputBarAccessoryView
  258. open var inputPlugins = [InputPlugin]()
  259. /// The InputBarItems held in the leftStackView
  260. public private(set) var leftStackViewItems: [InputItem] = []
  261. /// The InputBarItems held in the rightStackView
  262. public private(set) var rightStackViewItems: [InputItem] = []
  263. /// The InputBarItems held in the bottomStackView
  264. public private(set) var bottomStackViewItems: [InputItem] = []
  265. /// The InputBarItems held in the topStackView
  266. public private(set) var topStackViewItems: [InputItem] = []
  267. /// The InputBarItems held to make use of their hooks but they are not automatically added to a UIStackView
  268. open var nonStackViewItems: [InputItem] = []
  269. /// Returns a flatMap of all the items in each of the UIStackViews
  270. public var items: [InputItem] {
  271. return [leftStackViewItems, rightStackViewItems, bottomStackViewItems, topStackViewItems, nonStackViewItems].flatMap { $0 }
  272. }
  273. // customization
  274. /// indicates if a draft view was added to the InputBarAccessoryView
  275. var hasDraft: Bool = false
  276. /// indicates if a quote view was added to the InputBarAccessoryView
  277. var hasQuote: Bool = false
  278. /// optional callback that gets called if scroll down button was pressed
  279. var onScrollDownButtonPressed: (() -> Void)?
  280. lazy var scrollDownButton: UIButton = {
  281. let button = UIButton(frame: .zero)
  282. button.translatesAutoresizingMaskIntoConstraints = false
  283. button.addTarget(self, action: #selector(onScrollDownPressed), for: .touchUpInside)
  284. button.isHidden = true
  285. return button
  286. }()
  287. private let keyboardManager: KeyboardManager = KeyboardManager()
  288. open var keyboardHeight: CGFloat = 0
  289. // MARK: - Auto-Layout Constraint Sets
  290. private var middleContentViewLayoutSet: NSLayoutConstraintSet?
  291. private var textViewHeightAnchor: NSLayoutConstraint?
  292. private var topStackViewLayoutSet: NSLayoutConstraintSet?
  293. private var leftStackViewLayoutSet: NSLayoutConstraintSet?
  294. private var rightStackViewLayoutSet: NSLayoutConstraintSet?
  295. private var bottomStackViewLayoutSet: NSLayoutConstraintSet?
  296. private var contentViewLayoutSet: NSLayoutConstraintSet?
  297. private var windowAnchor: NSLayoutConstraint?
  298. private var backgroundViewLayoutSet: NSLayoutConstraintSet?
  299. // MARK: - Initialization
  300. public convenience init() {
  301. self.init(frame: .zero)
  302. }
  303. public override init(frame: CGRect) {
  304. super.init(frame: frame)
  305. setup()
  306. }
  307. required public init?(coder aDecoder: NSCoder) {
  308. super.init(coder: aDecoder)
  309. setup()
  310. }
  311. deinit {
  312. NotificationCenter.default.removeObserver(self)
  313. }
  314. open override func willMove(toSuperview newSuperview: UIView?) {
  315. super.willMove(toSuperview: newSuperview)
  316. guard newSuperview != nil else {
  317. deactivateConstraints()
  318. return
  319. }
  320. activateConstraints()
  321. }
  322. open override func didMoveToWindow() {
  323. super.didMoveToWindow()
  324. setupConstraints(to: window)
  325. }
  326. // MARK: - Setup
  327. /// Sets up the default properties
  328. open func setup() {
  329. backgroundColor = .clear
  330. autoresizingMask = [.flexibleHeight]
  331. setupSubviews()
  332. setupConstraints()
  333. setupObservers()
  334. setupGestureRecognizers()
  335. setupKeyboardEvents()
  336. setupScrollDownButton()
  337. }
  338. private func setupKeyboardEvents() {
  339. keyboardManager.on(event: .willChangeFrame, do: { [weak self] (notification) in
  340. guard let self = self else { return }
  341. self.keyboardHeight = notification.endFrame.height - self.intrinsicContentSize.height
  342. }).on(event: .didChangeFrame, do: { [weak self] (notification) in
  343. guard let self = self else { return }
  344. self.keyboardHeight = notification.endFrame.height - self.intrinsicContentSize.height
  345. }).on(event: .didShow, do: { [weak self] _ in
  346. guard let self = self else { return }
  347. if UIApplication.shared.statusBarOrientation.isLandscape && UIDevice.current.userInterfaceIdiom == .phone {
  348. self.orientationDidChange()
  349. }
  350. })
  351. }
  352. /// Adds the required notification observers
  353. private func setupObservers() {
  354. NotificationCenter.default.addObserver(self,
  355. selector: #selector(InputBarAccessoryView.orientationDidChange),
  356. name: UIDevice.orientationDidChangeNotification, object: nil)
  357. NotificationCenter.default.addObserver(self,
  358. selector: #selector(InputBarAccessoryView.inputTextViewDidChange),
  359. name: UITextView.textDidChangeNotification, object: inputTextView)
  360. NotificationCenter.default.addObserver(self,
  361. selector: #selector(InputBarAccessoryView.inputTextViewDidBeginEditing),
  362. name: UITextView.textDidBeginEditingNotification, object: inputTextView)
  363. NotificationCenter.default.addObserver(self,
  364. selector: #selector(InputBarAccessoryView.inputTextViewDidEndEditing),
  365. name: UITextView.textDidEndEditingNotification, object: inputTextView)
  366. }
  367. /// Adds a UISwipeGestureRecognizer for each direction to the InputTextView
  368. private func setupGestureRecognizers() {
  369. let directions: [UISwipeGestureRecognizer.Direction] = [.left, .right]
  370. for direction in directions {
  371. let gesture = UISwipeGestureRecognizer(target: self,
  372. action: #selector(InputBarAccessoryView.didSwipeTextView(_:)))
  373. gesture.direction = direction
  374. inputTextView.addGestureRecognizer(gesture)
  375. }
  376. }
  377. /// Adds all of the subviews
  378. private func setupSubviews() {
  379. addSubview(blurView)
  380. addSubview(backgroundView)
  381. addSubview(topStackView)
  382. addSubview(contentView)
  383. addSubview(separatorLine)
  384. contentView.addSubview(middleContentViewWrapper)
  385. contentView.addSubview(leftStackView)
  386. contentView.addSubview(rightStackView)
  387. contentView.addSubview(bottomStackView)
  388. middleContentViewWrapper.addSubview(inputTextView)
  389. middleContentView = inputTextView
  390. setStackViewItems([sendButton], forStack: .right, animated: false)
  391. backgroundView.backgroundColor = DcColors.defaultTransparentBackgroundColor
  392. blurView.fillSuperview()
  393. }
  394. /// Sets up the initial constraints of each subview
  395. private func setupConstraints() {
  396. // The constraints within the InputBarAccessoryView
  397. separatorLine.addConstraints(topAnchor, left: backgroundView.leftAnchor, right: backgroundView.rightAnchor, heightConstant: separatorLine.height)
  398. backgroundViewLayoutSet = NSLayoutConstraintSet(
  399. top: backgroundView.topAnchor.constraint(equalTo: topStackView.bottomAnchor),
  400. bottom: backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
  401. left: backgroundView.leftAnchor.constraint(equalTo: leftAnchor, constant: frameInsets.left),
  402. right: backgroundView.rightAnchor.constraint(equalTo: rightAnchor, constant: -frameInsets.right)
  403. )
  404. topStackViewLayoutSet = NSLayoutConstraintSet(
  405. top: topStackView.topAnchor.constraint(equalTo: topAnchor, constant: topStackViewPadding.top),
  406. bottom: topStackView.bottomAnchor.constraint(equalTo: contentView.topAnchor, constant: -padding.top),
  407. left: topStackView.leftAnchor.constraint(equalTo: safeAreaLayoutGuide.leftAnchor, constant: topStackViewPadding.left + frameInsets.left),
  408. right: topStackView.rightAnchor.constraint(equalTo: safeAreaLayoutGuide.rightAnchor, constant: -(topStackViewPadding.right + frameInsets.right))
  409. )
  410. contentViewLayoutSet = NSLayoutConstraintSet(
  411. top: contentView.topAnchor.constraint(equalTo: topStackView.bottomAnchor, constant: padding.top),
  412. bottom: contentView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -padding.bottom),
  413. left: contentView.leftAnchor.constraint(equalTo: safeAreaLayoutGuide.leftAnchor, constant: padding.left + frameInsets.left),
  414. right: contentView.rightAnchor.constraint(equalTo: safeAreaLayoutGuide.rightAnchor, constant: -(padding.right + frameInsets.right))
  415. )
  416. // Constraints Within the contentView
  417. middleContentViewLayoutSet = NSLayoutConstraintSet(
  418. top: middleContentViewWrapper.topAnchor.constraint(equalTo: contentView.topAnchor, constant: middleContentViewPadding.top),
  419. bottom: middleContentViewWrapper.bottomAnchor.constraint(equalTo: bottomStackView.topAnchor, constant: -middleContentViewPadding.bottom),
  420. left: middleContentViewWrapper.leftAnchor.constraint(equalTo: leftStackView.rightAnchor, constant: middleContentViewPadding.left),
  421. right: middleContentViewWrapper.rightAnchor.constraint(equalTo: rightStackView.leftAnchor, constant: -middleContentViewPadding.right)
  422. )
  423. inputTextView.fillSuperview()
  424. maxTextViewHeight = calculateMaxTextViewHeight()
  425. textViewHeightAnchor = inputTextView.heightAnchor.constraint(equalToConstant: maxTextViewHeight)
  426. leftStackViewLayoutSet = NSLayoutConstraintSet(
  427. top: leftStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0),
  428. bottom: leftStackView.bottomAnchor.constraint(equalTo: middleContentViewWrapper.bottomAnchor, constant: 0),
  429. left: leftStackView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 0),
  430. width: leftStackView.widthAnchor.constraint(equalToConstant: leftStackViewWidthConstant)
  431. )
  432. rightStackViewLayoutSet = NSLayoutConstraintSet(
  433. top: rightStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0),
  434. bottom: rightStackView.bottomAnchor.constraint(equalTo: middleContentViewWrapper.bottomAnchor, constant: 0),
  435. right: rightStackView.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: 0),
  436. width: rightStackView.widthAnchor.constraint(equalToConstant: rightStackViewWidthConstant)
  437. )
  438. bottomStackViewLayoutSet = NSLayoutConstraintSet(
  439. top: bottomStackView.topAnchor.constraint(equalTo: middleContentViewWrapper.bottomAnchor, constant: middleContentViewPadding.bottom),
  440. bottom: bottomStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0),
  441. left: bottomStackView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 0),
  442. right: bottomStackView.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: 0)
  443. )
  444. }
  445. /// Respect window safeAreaInsets
  446. /// Adds a constraint to anchor the bottomAnchor of the contentView to the window's safeAreaLayoutGuide.bottomAnchor
  447. ///
  448. /// - Parameter window: The window to anchor to
  449. private func setupConstraints(to window: UIWindow?) {
  450. guard let window = window, window.safeAreaInsets.bottom > 0 else { return }
  451. windowAnchor?.isActive = false
  452. windowAnchor = contentView.bottomAnchor.constraint(lessThanOrEqualToSystemSpacingBelow: window.safeAreaLayoutGuide.bottomAnchor, multiplier: 1)
  453. windowAnchor?.constant = -padding.bottom
  454. windowAnchor?.priority = UILayoutPriority(rawValue: 750)
  455. windowAnchor?.isActive = true
  456. backgroundViewLayoutSet?.bottom?.constant = window.safeAreaInsets.bottom
  457. }
  458. // MARK: - Constraint Layout Updates
  459. private func updateFrameInsets() {
  460. backgroundViewLayoutSet?.left?.constant = frameInsets.left
  461. backgroundViewLayoutSet?.right?.constant = -frameInsets.right
  462. updatePadding()
  463. updateTopStackViewPadding()
  464. }
  465. /// Updates the constraint constants that correspond to the padding UIEdgeInsets
  466. private func updatePadding() {
  467. topStackViewLayoutSet?.bottom?.constant = -padding.top
  468. contentViewLayoutSet?.top?.constant = padding.top
  469. contentViewLayoutSet?.left?.constant = padding.left + frameInsets.left
  470. contentViewLayoutSet?.right?.constant = -(padding.right + frameInsets.right)
  471. contentViewLayoutSet?.bottom?.constant = -padding.bottom
  472. windowAnchor?.constant = -padding.bottom
  473. }
  474. /// Updates the constraint constants that correspond to the middleContentViewPadding UIEdgeInsets
  475. private func updateMiddleContentViewPadding() {
  476. middleContentViewLayoutSet?.top?.constant = middleContentViewPadding.top
  477. middleContentViewLayoutSet?.left?.constant = middleContentViewPadding.left
  478. middleContentViewLayoutSet?.right?.constant = -middleContentViewPadding.right
  479. middleContentViewLayoutSet?.bottom?.constant = -middleContentViewPadding.bottom
  480. bottomStackViewLayoutSet?.top?.constant = middleContentViewPadding.bottom
  481. }
  482. /// Updates the constraint constants that correspond to the topStackViewPadding UIEdgeInsets
  483. private func updateTopStackViewPadding() {
  484. topStackViewLayoutSet?.top?.constant = topStackViewPadding.top
  485. topStackViewLayoutSet?.left?.constant = topStackViewPadding.left + frameInsets.left
  486. topStackViewLayoutSet?.right?.constant = -(topStackViewPadding.right + frameInsets.right)
  487. }
  488. /// Invalidates the view’s intrinsic content size
  489. open override func invalidateIntrinsicContentSize() {
  490. super.invalidateIntrinsicContentSize()
  491. cachedIntrinsicContentSize = calculateIntrinsicContentSize()
  492. if previousIntrinsicContentSize != cachedIntrinsicContentSize {
  493. delegate?.inputBar(self, didChangeIntrinsicContentTo: cachedIntrinsicContentSize)
  494. previousIntrinsicContentSize = cachedIntrinsicContentSize
  495. }
  496. }
  497. /// Calculates the correct intrinsicContentSize of the InputBarAccessoryView
  498. ///
  499. /// - Returns: The required intrinsicContentSize
  500. open func calculateIntrinsicContentSize() -> CGSize {
  501. var inputTextViewHeight = requiredInputTextViewHeight
  502. if inputTextViewHeight >= maxTextViewHeight {
  503. if !isOverMaxTextViewHeight {
  504. textViewHeightAnchor?.isActive = true
  505. inputTextView.isScrollEnabled = true
  506. isOverMaxTextViewHeight = true
  507. }
  508. inputTextViewHeight = maxTextViewHeight
  509. } else if isOverMaxTextViewHeight {
  510. textViewHeightAnchor?.isActive = false || shouldForceTextViewMaxHeight
  511. inputTextView.isScrollEnabled = false
  512. isOverMaxTextViewHeight = false
  513. inputTextView.invalidateIntrinsicContentSize()
  514. }
  515. // Calculate the required height
  516. let totalPadding = padding.top + padding.bottom + topStackViewPadding.top + middleContentViewPadding.top + middleContentViewPadding.bottom
  517. let topStackViewHeight = !topStackView.arrangedSubviews.isEmpty ? topStackView.bounds.height : 0
  518. let bottomStackViewHeight = !bottomStackView.arrangedSubviews.isEmpty ? bottomStackView.bounds.height : 0
  519. let verticalStackViewHeight = topStackViewHeight + bottomStackViewHeight
  520. let requiredHeight = inputTextViewHeight + totalPadding + verticalStackViewHeight
  521. return CGSize(width: UIView.noIntrinsicMetric, height: requiredHeight)
  522. }
  523. open override func layoutIfNeeded() {
  524. super.layoutIfNeeded()
  525. inputTextView.layoutIfNeeded()
  526. }
  527. open override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
  528. if !scrollDownButton.isHidden && scrollDownButton.point(inside: convert(point, to: scrollDownButton), with: event) {
  529. return true
  530. }
  531. guard frameInsets.left != 0 || frameInsets.right != 0 else {
  532. return super.point(inside: point, with: event)
  533. }
  534. // Allow touches to pass through base view
  535. return subviews.contains {
  536. !$0.isHidden && $0.point(inside: convert(point, to: $0), with: event)
  537. }
  538. }
  539. /// Returns the max height the InputTextView can grow to based on the UIScreen
  540. ///
  541. /// - Returns: Max Height
  542. open func calculateMaxTextViewHeight() -> CGFloat {
  543. if UIApplication.shared.statusBarOrientation.isPortrait || UIDevice.current.userInterfaceIdiom == .pad {
  544. let divisor: CGFloat = 3
  545. var subtract: CGFloat = 0
  546. subtract += hasDraft ? 90 : 0
  547. subtract += hasQuote ? 90 : 0
  548. let height = (UIScreen.main.bounds.height / divisor).rounded(.down) - subtract
  549. if height < 40 {
  550. return 40
  551. }
  552. return height
  553. } else {
  554. // landscape phone layout
  555. return UIScreen.main.bounds.height - keyboardHeight - padding.vertical
  556. }
  557. }
  558. // MARK: - Layout Helper Methods
  559. /// Layout the given InputStackView's
  560. ///
  561. /// - Parameter positions: The InputStackView's to layout
  562. public func layoutStackViews(_ positions: [InputStackView.Position] = [.left, .right, .bottom, .top]) {
  563. guard superview != nil else { return }
  564. for position in positions {
  565. switch position {
  566. case .left:
  567. leftStackView.setNeedsLayout()
  568. leftStackView.layoutIfNeeded()
  569. case .right:
  570. rightStackView.setNeedsLayout()
  571. rightStackView.layoutIfNeeded()
  572. case .bottom:
  573. bottomStackView.setNeedsLayout()
  574. bottomStackView.layoutIfNeeded()
  575. case .top:
  576. topStackView.setNeedsLayout()
  577. topStackView.layoutIfNeeded()
  578. }
  579. }
  580. }
  581. /// Performs a layout over the main thread
  582. ///
  583. /// - Parameters:
  584. /// - animated: If the layout should be animated
  585. /// - animations: Animation logic
  586. internal func performLayout(_ animated: Bool, _ animations: @escaping () -> Void) {
  587. deactivateConstraints()
  588. if animated {
  589. DispatchQueue.main.async {
  590. UIView.animate(withDuration: 0.3, animations: animations)
  591. }
  592. } else {
  593. UIView.performWithoutAnimation { animations() }
  594. }
  595. activateConstraints()
  596. }
  597. /// Activates the NSLayoutConstraintSet's
  598. private func activateConstraints() {
  599. backgroundViewLayoutSet?.activate()
  600. contentViewLayoutSet?.activate()
  601. middleContentViewLayoutSet?.activate()
  602. leftStackViewLayoutSet?.activate()
  603. rightStackViewLayoutSet?.activate()
  604. bottomStackViewLayoutSet?.activate()
  605. topStackViewLayoutSet?.activate()
  606. }
  607. /// Deactivates the NSLayoutConstraintSet's
  608. private func deactivateConstraints() {
  609. backgroundViewLayoutSet?.deactivate()
  610. contentViewLayoutSet?.deactivate()
  611. middleContentViewLayoutSet?.deactivate()
  612. leftStackViewLayoutSet?.deactivate()
  613. rightStackViewLayoutSet?.deactivate()
  614. bottomStackViewLayoutSet?.deactivate()
  615. topStackViewLayoutSet?.deactivate()
  616. }
  617. /// Removes the current `middleContentView` and assigns a new one.
  618. ///
  619. /// WARNING: This will remove the `InputTextView`
  620. ///
  621. /// - Parameters:
  622. /// - view: New view
  623. /// - animated: If the layout should be animated
  624. open func setMiddleContentView(_ view: UIView?, animated: Bool) {
  625. middleContentView?.removeFromSuperview()
  626. middleContentView = view
  627. guard let view = view else { return }
  628. middleContentViewWrapper.addSubview(view)
  629. view.fillSuperview()
  630. performLayout(animated) { [weak self] in
  631. guard self?.superview != nil else { return }
  632. self?.middleContentViewWrapper.layoutIfNeeded()
  633. self?.invalidateIntrinsicContentSize()
  634. }
  635. }
  636. /// Removes all of the arranged subviews from the InputStackView and adds the given items.
  637. /// Sets the inputBarAccessoryView property of the InputBarButtonItem
  638. ///
  639. /// Note: If you call `animated = true`, the `items` property of the stack view items will not be updated until the
  640. /// views are done being animated. If you perform a check for the items after they're set, setting animated to `false`
  641. /// will apply the body of the closure immediately.
  642. ///
  643. /// The send button is attached to `rightStackView` so remember to remove it if you're setting it to a different
  644. /// stack.
  645. ///
  646. /// - Parameters:
  647. /// - items: New InputStackView arranged views
  648. /// - position: The targeted InputStackView
  649. /// - animated: If the layout should be animated
  650. open func setStackViewItems(_ items: [InputItem], forStack position: InputStackView.Position, animated: Bool) {
  651. func setNewItems() {
  652. switch position {
  653. case .left:
  654. leftStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
  655. leftStackViewItems = items
  656. leftStackViewItems.forEach {
  657. $0.inputBarAccessoryView = self
  658. $0.parentStackViewPosition = position
  659. if let view = $0 as? UIView {
  660. leftStackView.addArrangedSubview(view)
  661. }
  662. }
  663. guard superview != nil else { return }
  664. leftStackView.layoutIfNeeded()
  665. case .right:
  666. rightStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
  667. rightStackViewItems = items
  668. rightStackViewItems.forEach {
  669. $0.inputBarAccessoryView = self
  670. $0.parentStackViewPosition = position
  671. if let view = $0 as? UIView {
  672. rightStackView.addArrangedSubview(view)
  673. }
  674. }
  675. guard superview != nil else { return }
  676. rightStackView.layoutIfNeeded()
  677. case .bottom:
  678. bottomStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
  679. bottomStackViewItems = items
  680. bottomStackViewItems.forEach {
  681. $0.inputBarAccessoryView = self
  682. $0.parentStackViewPosition = position
  683. if let view = $0 as? UIView {
  684. bottomStackView.addArrangedSubview(view)
  685. }
  686. }
  687. guard superview != nil else { return }
  688. bottomStackView.layoutIfNeeded()
  689. case .top:
  690. topStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
  691. topStackViewItems = items
  692. topStackViewItems.forEach {
  693. $0.inputBarAccessoryView = self
  694. $0.parentStackViewPosition = position
  695. if let view = $0 as? UIView {
  696. topStackView.addArrangedSubview(view)
  697. }
  698. }
  699. guard superview != nil else { return }
  700. topStackView.layoutIfNeeded()
  701. }
  702. invalidateIntrinsicContentSize()
  703. }
  704. performLayout(animated) {
  705. setNewItems()
  706. }
  707. }
  708. /// Sets the leftStackViewWidthConstant
  709. ///
  710. /// - Parameters:
  711. /// - newValue: New widthAnchor constant
  712. /// - animated: If the layout should be animated
  713. open func setLeftStackViewWidthConstant(to newValue: CGFloat, animated: Bool) {
  714. performLayout(animated) {
  715. self.leftStackViewWidthConstant = newValue
  716. self.layoutStackViews([.left])
  717. self.layoutContainerViewIfNeeded()
  718. }
  719. }
  720. /// Sets the rightStackViewWidthConstant
  721. ///
  722. /// - Parameters:
  723. /// - newValue: New widthAnchor constant
  724. /// - animated: If the layout should be animated
  725. open func setRightStackViewWidthConstant(to newValue: CGFloat, animated: Bool) {
  726. performLayout(animated) {
  727. self.rightStackViewWidthConstant = newValue
  728. self.layoutStackViews([.right])
  729. self.layoutContainerViewIfNeeded()
  730. }
  731. }
  732. /// Sets the `shouldForceTextViewMaxHeight` property
  733. ///
  734. /// - Parameters:
  735. /// - newValue: New boolean value
  736. /// - animated: If the layout should be animated
  737. open func setShouldForceMaxTextViewHeight(to newValue: Bool, animated: Bool) {
  738. performLayout(animated) {
  739. self.shouldForceTextViewMaxHeight = newValue
  740. self.textViewHeightAnchor?.isActive = newValue
  741. self.layoutContainerViewIfNeeded()
  742. }
  743. }
  744. /// Calls `layoutIfNeeded()` on the `UIInputSetContainerView` that holds the
  745. /// `InputBarAccessoryView`, if it exists, else `layoutIfNeeded()` is called
  746. /// on the `superview`.
  747. /// Use this for invoking a smooth layout of a size change when used as
  748. /// an `inputAccessoryView`
  749. public func layoutContainerViewIfNeeded() {
  750. guard
  751. let UIInputSetContainerViewKind: AnyClass = NSClassFromString("UIInputSetContainerView"),
  752. let container = superview?.superview,
  753. container.isKind(of: UIInputSetContainerViewKind) else {
  754. superview?.layoutIfNeeded()
  755. return
  756. }
  757. superview?.superview?.layoutIfNeeded()
  758. }
  759. // MARK: - Notifications/Hooks
  760. /// Invalidates the intrinsicContentSize
  761. open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  762. super.traitCollectionDidChange(previousTraitCollection)
  763. if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass ||
  764. traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass {
  765. if shouldAutoUpdateMaxTextViewHeight {
  766. maxTextViewHeight = calculateMaxTextViewHeight()
  767. } else {
  768. invalidateIntrinsicContentSize()
  769. }
  770. }
  771. scrollDownButton.layer.borderColor = DcColors.colorDisabled.cgColor
  772. }
  773. /// Invalidates the intrinsicContentSize
  774. @objc
  775. open func orientationDidChange() {
  776. if shouldAutoUpdateMaxTextViewHeight {
  777. maxTextViewHeight = calculateMaxTextViewHeight()
  778. }
  779. invalidateIntrinsicContentSize()
  780. }
  781. /// Enables/Disables the sendButton based on the InputTextView's text being empty
  782. /// Calls each items `textViewDidChangeAction` method
  783. /// Calls the delegates `textViewTextDidChangeTo` method
  784. /// Invalidates the intrinsicContentSize
  785. @objc
  786. open func inputTextViewDidChange() {
  787. let trimmedText = inputTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
  788. if shouldManageSendButtonEnabledState {
  789. var isEnabled = !trimmedText.isEmpty
  790. if !isEnabled {
  791. // The images property is more resource intensive so only use it if needed
  792. isEnabled = !inputTextView.images.isEmpty
  793. }
  794. sendButton.isEnabled = isEnabled
  795. }
  796. // Capture change before iterating over the InputItem's
  797. let shouldInvalidateIntrinsicContentSize = requiredInputTextViewHeight != inputTextView.bounds.height
  798. items.forEach { $0.textViewDidChangeAction(with: self.inputTextView) }
  799. delegate?.inputBar(self, textViewTextDidChangeTo: trimmedText)
  800. if shouldInvalidateIntrinsicContentSize {
  801. // Prevent un-needed content size invalidation
  802. invalidateIntrinsicContentSize()
  803. if shouldAnimateTextDidChangeLayout {
  804. inputTextView.layoutIfNeeded()
  805. UIView.animate(withDuration: 0.15) {
  806. self.layoutContainerViewIfNeeded()
  807. }
  808. }
  809. }
  810. }
  811. /// Calls each items `keyboardEditingBeginsAction` method
  812. @objc
  813. open func inputTextViewDidBeginEditing() {
  814. items.forEach { $0.keyboardEditingBeginsAction() }
  815. }
  816. /// Calls each items `keyboardEditingEndsAction` method
  817. @objc
  818. open func inputTextViewDidEndEditing() {
  819. items.forEach { $0.keyboardEditingEndsAction() }
  820. }
  821. // MARK: - Plugins
  822. /// Reloads each of the plugins
  823. open func reloadPlugins() {
  824. inputPlugins.forEach { $0.reloadData() }
  825. }
  826. /// Invalidates each of the plugins
  827. open func invalidatePlugins() {
  828. inputPlugins.forEach { $0.invalidate() }
  829. }
  830. // MARK: - User Actions
  831. /// Calls each items `keyboardSwipeGestureAction` method
  832. /// Calls the delegates `didSwipeTextViewWith` method
  833. @objc
  834. open func didSwipeTextView(_ gesture: UISwipeGestureRecognizer) {
  835. items.forEach { $0.keyboardSwipeGestureAction(with: gesture) }
  836. delegate?.inputBar(self, didSwipeTextViewWith: gesture)
  837. }
  838. /// Calls the delegates `didPressSendButtonWith` method
  839. /// Assumes that the InputTextView's text has been set to empty and calls `inputTextViewDidChange()`
  840. /// Invalidates each of the InputPlugins
  841. open func didSelectSendButton() {
  842. delegate?.inputBar(self, didPressSendButtonWith: inputTextView.text)
  843. }
  844. // MARK: - Drafts - Customization
  845. public func configure(draft: DraftModel) {
  846. hasDraft = !draft.isEditing && draft.attachment != nil
  847. hasQuote = !draft.isEditing && draft.quoteText != nil
  848. leftStackView.isHidden = draft.isEditing
  849. rightStackView.isHidden = draft.isEditing
  850. maxTextViewHeight = calculateMaxTextViewHeight()
  851. }
  852. public func cancel() {
  853. hasDraft = false
  854. hasQuote = false
  855. maxTextViewHeight = calculateMaxTextViewHeight()
  856. }
  857. @objc func onScrollDownPressed() {
  858. if let callback = onScrollDownButtonPressed {
  859. callback()
  860. }
  861. }
  862. func setupScrollDownButton() {
  863. self.addSubview(scrollDownButton)
  864. NSLayoutConstraint.activate([
  865. scrollDownButton.constraintAlignTopTo(self, paddingTop: -52),
  866. scrollDownButton.constraintAlignTrailingToAnchor(self.safeAreaLayoutGuide.trailingAnchor, paddingTrailing: 12),
  867. scrollDownButton.constraintHeightTo(40),
  868. scrollDownButton.constraintWidthTo(40)
  869. ])
  870. scrollDownButton.backgroundColor = DcColors.defaultBackgroundColor
  871. scrollDownButton.setImage(UIImage(named: "ic_scrolldown")?.sd_tintedImage(with: .systemBlue), for: .normal)
  872. scrollDownButton.layer.cornerRadius = 20
  873. scrollDownButton.layer.borderColor = DcColors.colorDisabled.cgColor
  874. scrollDownButton.layer.borderWidth = 1
  875. scrollDownButton.layer.masksToBounds = true
  876. scrollDownButton.accessibilityLabel = String.localized("menu_scroll_to_bottom")
  877. }
  878. public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  879. if !scrollDownButton.isHidden {
  880. let scrollButtonViewPoint = self.scrollDownButton.convert(point, from: self)
  881. if let view = scrollDownButton.hitTest(scrollButtonViewPoint, with: event) {
  882. return view
  883. }
  884. }
  885. return super.hitTest(point, with: event)
  886. }
  887. }