diff --git a/Sources/Views/InputStackView.swift b/Sources/Views/InputStackView.swift index 522047f2..347d7d9a 100644 --- a/Sources/Views/InputStackView.swift +++ b/Sources/Views/InputStackView.swift @@ -40,7 +40,7 @@ open class InputStackView: UIStackView { /// - right: Bottom Stack View /// - bottom: Left Stack View public enum Position { - case left, right, bottom + case left, right, bottom, top } // MARK: Initialization diff --git a/Sources/Views/MessageInputBar.swift b/Sources/Views/MessageInputBar.swift index af5baa97..7737196e 100644 --- a/Sources/Views/MessageInputBar.swift +++ b/Sources/Views/MessageInputBar.swift @@ -24,6 +24,7 @@ import UIKit +/// A powerful InputAccessoryView ideal for messaging applications open class MessageInputBar: UIView { public enum UIStackViewPosition { @@ -32,28 +33,61 @@ open class MessageInputBar: UIView { // MARK: - Properties + /// A delegate to broadcast notifications from the MessageInputBar open weak var delegate: MessageInputBarDelegate? - /// A background view that adds a blur effect. Shown when 'isTransparent' is set to TRUE. Hidden by default. - open let blurView: UIView = { - let blurEffect = UIBlurEffect(style: .extraLight) - let view = UIVisualEffectView(effect: blurEffect) + /// The background UIView anchored to the bottom, left, and right of the MessageInputBar + /// with a top anchor equal to the bottom of the top InputStackView + open var backgroundView: UIView = { + let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false - view.isHidden = true + view.backgroundColor = .white return view }() - /// When set to true, the blurView in the background is shown and the backgroundColor is set to .clear. Default is FALSE - open var isTranslucent: Bool = false { + /// Also sets the backgroundView's backgroundColor to the newValue + open override var backgroundColor: UIColor? { didSet { - blurView.isHidden = !isTranslucent - backgroundColor = isTranslucent ? .clear : .white + backgroundView.backgroundColor = backgroundColor } } - /// A boarder line anchored to the top of the view + /** + A UIVisualEffectView that adds a blur effect to make the view appear transparent. + + ## Important Notes ## + 1. The blurView is initially not added to the backgroundView to improve performance when not needed. When `isTranslucent` is set to TRUE for the first time the blurView is added and anchored to the `backgroundView`s edge anchors + */ + open var blurView: UIVisualEffectView = { + let blurEffect = UIBlurEffect(style: .light) + let view = UIVisualEffectView(effect: blurEffect) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Determines if the MessageInputBar should have a translucent effect + open var isTranslucent: Bool = false { + didSet { + if isTranslucent && blurView.superview == nil { + backgroundView.addSubview(blurView) + blurView.fillSuperview() + } + blurView.isHidden = !isTranslucent + backgroundView.backgroundColor = isTranslucent ? (backgroundView.backgroundColor?.withAlphaComponent(0.7) ?? UIColor.white.withAlphaComponent(0.7)) : .white + } + } + + /// A SeparatorLine that is initially placed in the topStackView open let separatorLine = SeparatorLine() + /** + The InputStackView at the InputStackView.top position + + ## Important Notes ## + 1. It's axis is initially set to .vertical + */ + open let topStackView = InputStackView(axis: .vertical, spacing: 0) + /** The InputStackView at the InputStackView.left position @@ -78,24 +112,16 @@ open class MessageInputBar: UIView { 2. It's spacing is initially set to 15 */ open let bottomStackView = InputStackView(axis: .horizontal, spacing: 15) - + + /// The InputTextView a user can input a message in open lazy var inputTextView: InputTextView = { [weak self] in let textView = InputTextView() textView.translatesAutoresizingMaskIntoConstraints = false textView.messageInputBar = self return textView }() - - /// The padding around the textView that separates it from the stackViews - open var textViewPadding: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) { - didSet { - textViewLayoutSet?.bottom?.constant = -textViewPadding.bottom - textViewLayoutSet?.left?.constant = textViewPadding.left - textViewLayoutSet?.right?.constant = -textViewPadding.right - bottomStackViewLayoutSet?.top?.constant = textViewPadding.bottom - } - } + /// A InputBarButtonItem used as the send button and initially placed in the rightStackView open var sendButton: InputBarButtonItem = { return InputBarButtonItem() .configure { @@ -108,46 +134,82 @@ open class MessageInputBar: UIView { } }() - /// The anchor contants used by the UIStackViews and InputTextView to create padding within the InputBarAccessoryView + /** + The anchor contants used by the InputStackView's and InputTextView to create padding + within the MessageInputBar + + ## Important Notes ## + + ```` + V:|...-[InputStackView.top]-(padding.top)-[InputTextView]-(textViewPadding.bottom)-[InputStackView.bottom]-(padding.bottom)-| + + H:|-(padding.left)-[InputStackView.left(leftStackViewWidthConstant)]-(textViewPadding.left)-[InputTextView]-(textViewPadding.right)-[InputStackView.right(rightStackViewWidthConstant)]-(padding.right)-| + ```` + + */ open var padding: UIEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) { didSet { - updateViewContraints() + updatePadding() + } + } + + /** + The anchor constants used by the top InputStackView + + ## Important Notes ## + 1. The topStackViewPadding.bottom property is not used. Use padding.top + + ```` + V:|-(topStackViewPadding.top)-[InputStackView.top]-(padding.top)-[InputTextView]-...| + + H:|-(topStackViewPadding.left)-[InputStackView.top]-(topStackViewPadding.right)-| + ```` + + */ + open var topStackViewPadding: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) { + didSet { + updateTopStackViewPadding() + } + } + + /** + The anchor constants used by the InputStackView + + ## Important Notes ## + 1. The inputTextViewPadding.top property is not used. Use padding.top + + ```` + V:|...-(padding.top)-[InputTextView]-(inputTextViewPadding.bottom)-[InputStackView.bottom]-...| + + H:|...-[InputStackView.left]-(inputTextViewPadding.left)-[InputTextView]-(inputTextViewPadding.left)-[InputStackView.left.right]-...| + ```` + + */ + open var textViewPadding: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) { + didSet { + updateTextViewPadding() } } open override var intrinsicContentSize: CGSize { - let maxSize = CGSize(width: inputTextView.bounds.width, height: .greatestFiniteMagnitude) - let sizeToFit = inputTextView.sizeThatFits(maxSize) - var heightToFit = sizeToFit.height.rounded() + padding.top + padding.bottom - - if heightToFit >= maxHeight { - if !isOverMaxHeight { - textViewHeightAnchor?.isActive = true - inputTextView.isScrollEnabled = true - isOverMaxHeight = true - } - heightToFit = maxHeight - } else { - if isOverMaxHeight { - textViewHeightAnchor?.isActive = false - inputTextView.isScrollEnabled = false - isOverMaxHeight = false - } - } - - let size = CGSize(width: bounds.width, height: heightToFit) - + let size = calculateIntrinsicContentSize() if previousIntrinsicContentSize != size { delegate?.messageInputBar(self, didChangeIntrinsicContentTo: size) previousIntrinsicContentSize = size } - return size } - private(set) var isOverMaxHeight = false + /// The intrinsicContentSize can change a lot so the delegate method + /// `inputBar(self, didChangeIntrinsicContentTo: size)` only needs to be called + /// when it's different + private var previousIntrinsicContentSize: CGSize? - /// The maximum intrinsicContentSize height. When reached the delegate 'didChangeIntrinsicContentTo' will be called. + /// A boolean that indicates if the maxTextViewHeight has been met. Keeping track of this + /// improves the performance + private(set) public var isOverMaxTextViewHeight = false + + /// The maximum height that the InputTextView can reach open var maxHeight: CGFloat = UIScreen.main.bounds.height / 3 { didSet { textViewHeightAnchor?.constant = maxHeight @@ -156,27 +218,30 @@ open class MessageInputBar: UIView { } /// The fixed widthAnchor constant of the leftStackView - private(set) var leftStackViewWidthContant: CGFloat = 0 { + private(set) var leftStackViewWidthConstant: CGFloat = 0 { didSet { - leftStackViewLayoutSet?.width?.constant = leftStackViewWidthContant + leftStackViewLayoutSet?.width?.constant = leftStackViewWidthConstant } } /// The fixed widthAnchor constant of the rightStackView - private(set) var rightStackViewWidthContant: CGFloat = 52 { + private(set) var rightStackViewWidthConstant: CGFloat = 52 { didSet { - rightStackViewLayoutSet?.width?.constant = rightStackViewWidthContant + rightStackViewLayoutSet?.width?.constant = rightStackViewWidthConstant } } /// The InputBarItems held in the leftStackView - private(set) var leftStackViewItems: [InputBarButtonItem] = [] + private(set) public var leftStackViewItems: [InputBarButtonItem] = [] /// The InputBarItems held in the rightStackView - private(set) var rightStackViewItems: [InputBarButtonItem] = [] + private(set) public var rightStackViewItems: [InputBarButtonItem] = [] /// The InputBarItems held in the bottomStackView - private(set) var bottomStackViewItems: [InputBarButtonItem] = [] + private(set) public var bottomStackViewItems: [InputBarButtonItem] = [] + + /// The InputBarItems held in the topStackView + private(set) public var topStackViewItems: [InputBarButtonItem] = [] /// The InputBarItems held to make use of their hooks but they are not automatically added to a UIStackView open var nonStackViewItems: [InputBarButtonItem] = [] @@ -190,10 +255,11 @@ open class MessageInputBar: UIView { private var textViewLayoutSet: NSLayoutConstraintSet? private var textViewHeightAnchor: NSLayoutConstraint? + private var topStackViewHeightAnchor: NSLayoutConstraint? + private var topStackViewLayoutSet: NSLayoutConstraintSet? private var leftStackViewLayoutSet: NSLayoutConstraintSet? private var rightStackViewLayoutSet: NSLayoutConstraintSet? private var bottomStackViewLayoutSet: NSLayoutConstraintSet? - private var previousIntrinsicContentSize: CGSize? // MARK: - Initialization @@ -217,6 +283,7 @@ open class MessageInputBar: UIView { // MARK: - Setup + /// Sets up the default properties open func setup() { backgroundColor = .inputBarGray @@ -226,24 +293,32 @@ open class MessageInputBar: UIView { setupObservers() } + /// Adds all of the subviews private func setupSubviews() { - addSubview(blurView) + addSubview(backgroundView) + addSubview(topStackView) addSubview(inputTextView) addSubview(leftStackView) addSubview(rightStackView) addSubview(bottomStackView) - addSubview(separatorLine) + topStackView.addArrangedSubview(separatorLine) setStackViewItems([sendButton], forStack: .right, animated: false) } + /// Sets up the initial constraints of each subview private func setupConstraints() { - separatorLine.addConstraints(topAnchor, left: leftAnchor, right: rightAnchor, heightConstant: 0.5) - blurView.fillSuperview() + topStackViewLayoutSet = NSLayoutConstraintSet( + top: topStackView.topAnchor.constraint(equalTo: topAnchor, constant: topStackViewPadding.top), + bottom: topStackView.bottomAnchor.constraint(equalTo: inputTextView.topAnchor, constant: -padding.top), + left: topStackView.leftAnchor.constraint(equalTo: leftAnchor, constant: topStackViewPadding.left), + right: topStackView.rightAnchor.constraint(equalTo: rightAnchor, constant: -topStackViewPadding.right) + ) + backgroundView.addConstraints(topStackView.bottomAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor) textViewLayoutSet = NSLayoutConstraintSet( - top: inputTextView.topAnchor.constraint(equalTo: topAnchor, constant: padding.top), + top: inputTextView.topAnchor.constraint(equalTo: topStackView.bottomAnchor, constant: padding.top), bottom: inputTextView.bottomAnchor.constraint(equalTo: bottomStackView.topAnchor, constant: -textViewPadding.bottom), left: inputTextView.leftAnchor.constraint(equalTo: leftStackView.rightAnchor, constant: textViewPadding.left), right: inputTextView.rightAnchor.constraint(equalTo: rightStackView.leftAnchor, constant: -textViewPadding.right) @@ -254,14 +329,14 @@ open class MessageInputBar: UIView { top: leftStackView.topAnchor.constraint(equalTo: topAnchor, constant: padding.top), bottom: leftStackView.bottomAnchor.constraint(equalTo: inputTextView.bottomAnchor, constant: 0), left: leftStackView.leftAnchor.constraint(equalTo: leftAnchor, constant: padding.left), - width: leftStackView.widthAnchor.constraint(equalToConstant: leftStackViewWidthContant) + width: leftStackView.widthAnchor.constraint(equalToConstant: leftStackViewWidthConstant) ) rightStackViewLayoutSet = NSLayoutConstraintSet( top: rightStackView.topAnchor.constraint(equalTo: topAnchor, constant: padding.top), bottom: rightStackView.bottomAnchor.constraint(equalTo: inputTextView.bottomAnchor, constant: 0), right: rightStackView.rightAnchor.constraint(equalTo: rightAnchor, constant: -padding.right), - width: rightStackView.widthAnchor.constraint(equalToConstant: rightStackViewWidthContant) + width: rightStackView.widthAnchor.constraint(equalToConstant: rightStackViewWidthConstant) ) bottomStackViewLayoutSet = NSLayoutConstraintSet( @@ -273,19 +348,22 @@ open class MessageInputBar: UIView { if #available(iOS 11.0, *) { // Switch to safeAreaLayoutGuide + topStackViewLayoutSet?.left = topStackView.leftAnchor.constraint(equalTo: safeAreaLayoutGuide.leftAnchor, constant: topStackViewPadding.left) + topStackViewLayoutSet?.right = topStackView.rightAnchor.constraint(equalTo: safeAreaLayoutGuide.rightAnchor, constant: topStackViewPadding.right) leftStackViewLayoutSet?.left = leftStackView.leftAnchor.constraint(equalTo: safeAreaLayoutGuide.leftAnchor, constant: padding.left) rightStackViewLayoutSet?.right = rightStackView.rightAnchor.constraint(equalTo: safeAreaLayoutGuide.rightAnchor, constant: -padding.right) bottomStackViewLayoutSet?.bottom = bottomStackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -padding.bottom) bottomStackViewLayoutSet?.left = bottomStackView.leftAnchor.constraint(equalTo: safeAreaLayoutGuide.leftAnchor, constant: padding.left) bottomStackViewLayoutSet?.right = bottomStackView.rightAnchor.constraint(equalTo: safeAreaLayoutGuide.rightAnchor, constant: -padding.right) } + topStackViewLayoutSet?.activate() leftStackViewLayoutSet?.activate() rightStackViewLayoutSet?.activate() bottomStackViewLayoutSet?.activate() } - private func updateViewContraints() { - + /// Updates the constraint constants that correspond to the padding UIEdgeInsets + private func updatePadding() { textViewLayoutSet?.top?.constant = padding.top leftStackViewLayoutSet?.top?.constant = padding.top leftStackViewLayoutSet?.left?.constant = padding.left @@ -296,6 +374,22 @@ open class MessageInputBar: UIView { bottomStackViewLayoutSet?.bottom?.constant = -padding.bottom } + /// Updates the constraint constants that correspond to the inputTextViewPadding UIEdgeInsets + private func updateTextViewPadding() { + textViewLayoutSet?.left?.constant = textViewPadding.left + textViewLayoutSet?.right?.constant = -textViewPadding.right + textViewLayoutSet?.bottom?.constant = -textViewPadding.bottom + bottomStackViewLayoutSet?.top?.constant = textViewPadding.bottom + } + + /// Updates the constraint constants that correspond to the topStackViewPadding UIEdgeInsets + private func updateTopStackViewPadding() { + topStackViewLayoutSet?.top?.constant = topStackViewPadding.top + topStackViewLayoutSet?.left?.constant = topStackViewPadding.left + topStackViewLayoutSet?.right?.constant = -topStackViewPadding.right + } + + /// Adds the required notification observers private func setupObservers() { NotificationCenter.default.addObserver(self, @@ -312,6 +406,30 @@ open class MessageInputBar: UIView { name: .UITextViewTextDidEndEditing, object: nil) } + /// Calculates the correct intrinsicContentSize of the MessageInputBar + /// + /// - Returns: The required intrinsicContentSize + open func calculateIntrinsicContentSize() -> CGSize { + + let maxTextViewSize = CGSize(width: inputTextView.bounds.width, height: .greatestFiniteMagnitude) + var heightToFit = inputTextView.sizeThatFits(maxTextViewSize).height.rounded() + if heightToFit >= maxHeight { + if !isOverMaxTextViewHeight { + textViewHeightAnchor?.isActive = true + inputTextView.isScrollEnabled = true + isOverMaxTextViewHeight = true + } + heightToFit = maxHeight + } else { + if isOverMaxTextViewHeight { + textViewHeightAnchor?.isActive = false + inputTextView.isScrollEnabled = false + isOverMaxTextViewHeight = false + } + } + return CGSize(width: bounds.width, height: heightToFit) + } + // MARK: - Layout Helper Methods /// Layout the given UIStackView's @@ -330,6 +448,9 @@ open class MessageInputBar: UIView { case .bottom: bottomStackView.setNeedsLayout() bottomStackView.layoutIfNeeded() + case .top: + bottomStackView.setNeedsLayout() + bottomStackView.layoutIfNeeded() } } } @@ -345,6 +466,7 @@ open class MessageInputBar: UIView { leftStackViewLayoutSet?.deactivate() rightStackViewLayoutSet?.deactivate() bottomStackViewLayoutSet?.deactivate() + topStackViewLayoutSet?.deactivate() if animated { DispatchQueue.main.async { UIView.animate(withDuration: 0.3, animations: animations) @@ -356,11 +478,12 @@ open class MessageInputBar: UIView { leftStackViewLayoutSet?.activate() rightStackViewLayoutSet?.activate() bottomStackViewLayoutSet?.activate() + topStackViewLayoutSet?.activate() } // MARK: - UIStackView InputBarItem Methods - /// Removes all of the arranged subviews from the UIStackView and adds the given items. Sets the inputBarAccessoryView property of the InputBarButtonItem + /// Removes all of the arranged subviews from the UIStackView and adds the given items. Sets the messageInputBar property of the InputBarButtonItem /// /// - Parameters: /// - items: New UIStackView arranged views @@ -397,6 +520,15 @@ open class MessageInputBar: UIView { bottomStackView.addArrangedSubview($0) } bottomStackView.layoutIfNeeded() + case .top: + topStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + topStackViewItems = items + topStackViewItems.forEach { + $0.messageInputBar = self + $0.parentStackViewPosition = position + topStackView.addArrangedSubview($0) + } + topStackView.layoutIfNeeded() } } @@ -412,7 +544,7 @@ open class MessageInputBar: UIView { /// - animated: If the layout should be animated open func setLeftStackViewWidthConstant(to newValue: CGFloat, animated: Bool) { performLayout(animated) { - self.leftStackViewWidthContant = newValue + self.leftStackViewWidthConstant = newValue self.layoutStackViews([.left]) self.layoutIfNeeded() } @@ -425,7 +557,7 @@ open class MessageInputBar: UIView { /// - animated: If the layout should be animated open func setRightStackViewWidthConstant(to newValue: CGFloat, animated: Bool) { performLayout(animated) { - self.rightStackViewWidthContant = newValue + self.rightStackViewWidthConstant = newValue self.layoutStackViews([.right]) self.layoutIfNeeded() } @@ -433,11 +565,16 @@ open class MessageInputBar: UIView { // MARK: - Notifications/Hooks + /// Invalidates the intrinsicContentSize @objc open func orientationDidChange() { invalidateIntrinsicContentSize() } + /// Enables/Disables the sendButton based on the InputTextView's text being empty + /// Calls each items `textViewDidChangeAction` method + /// Calls the delegates `textViewTextDidChangeTo` method + /// Invalidates the intrinsicContentSize @objc open func textViewDidChange() { let trimmedText = inputTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -451,11 +588,13 @@ open class MessageInputBar: UIView { invalidateIntrinsicContentSize() } + /// Calls each items `keyboardEditingBeginsAction` method @objc open func textViewDidBeginEditing() { self.items.forEach { $0.keyboardEditingBeginsAction() } } + /// Calls each items `keyboardEditingEndsAction` method @objc open func textViewDidEndEditing() { self.items.forEach { $0.keyboardEditingEndsAction() } @@ -463,6 +602,9 @@ open class MessageInputBar: UIView { // MARK: - User Actions + /// Calls the delegates `didPressSendButtonWith` method + /// Assumes that the InputTextView's text has been set to empty and calls `inputTextViewDidChange()` + /// Invalidates each of the inputManagers open func didSelectSendButton() { delegate?.messageInputBar(self, didPressSendButtonWith: inputTextView.text) textViewDidChange()