Files
MessageKit/Sources/MessageInputBar.swift
T
2017-09-27 23:07:42 -07:00

466 lines
18 KiB
Swift

/*
MIT License
Copyright (c) 2017 MessageKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import UIKit
open class MessageInputBar: UIView {
public enum UIStackViewPosition {
case left, right, bottom
}
// MARK: - Properties
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)
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
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 {
didSet {
blurView.isHidden = !isTranslucent
backgroundColor = isTranslucent ? .clear : .white
}
}
/// A boarder line anchored to the top of the view
open let separatorLine: UIView = {
let view = UIView()
view.backgroundColor = .lightGray
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
open let leftStackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.translatesAutoresizingMaskIntoConstraints = false
view.distribution = .fill
view.alignment = .fill
view.spacing = 15
return view
}()
open let rightStackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.translatesAutoresizingMaskIntoConstraints = false
view.distribution = .fill
view.alignment = .fill
view.spacing = 15
return view
}()
open let bottomStackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.translatesAutoresizingMaskIntoConstraints = false
view.distribution = .fill
view.alignment = .fill
view.spacing = 15
return view
}()
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
}
}
open var sendButton: InputBarButtonItem = {
return InputBarButtonItem()
.configure {
$0.setSize(CGSize(width: 52, height: 28), animated: false)
$0.isEnabled = false
$0.title = "Send"
$0.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline)
}.onTouchUpInside {
$0.messageInputBar?.didSelectSendButton()
}
}()
/// The anchor contants used by the UIStackViews and InputTextView to create padding within the InputBarAccessoryView
open var padding: UIEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) {
didSet {
updateViewContraints()
}
}
override open 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
}
}
inputTextView.invalidateIntrinsicContentSize()
let size = CGSize(width: bounds.width, height: heightToFit)
if previousIntrinsicContentSize != size {
delegate?.messageInputBar(self, didChangeIntrinsicContentTo: size)
}
previousIntrinsicContentSize = size
return size
}
private(set) var isOverMaxHeight = false
/// The maximum intrinsicContentSize height. When reached the delegate 'didChangeIntrinsicContentTo' will be called.
open var maxHeight: CGFloat = UIScreen.main.bounds.height / 3 {
didSet {
textViewHeightAnchor?.constant = maxHeight
invalidateIntrinsicContentSize()
}
}
/// The fixed widthAnchor constant of the leftStackView
private(set) var leftStackViewWidthContant: CGFloat = 0 {
didSet {
leftStackViewLayoutSet?.width?.constant = leftStackViewWidthContant
}
}
/// The fixed widthAnchor constant of the rightStackView
private(set) var rightStackViewWidthContant: CGFloat = 52 {
didSet {
rightStackViewLayoutSet?.width?.constant = rightStackViewWidthContant
}
}
/// The InputBarItems held in the leftStackView
private(set) var leftStackViewItems: [InputBarButtonItem] = []
/// The InputBarItems held in the rightStackView
private(set) var rightStackViewItems: [InputBarButtonItem] = []
/// The InputBarItems held in the bottomStackView
private(set) var bottomStackViewItems: [InputBarButtonItem] = []
/// The InputBarItems held to make use of their hooks but they are not automatically added to a UIStackView
open var nonStackViewItems: [InputBarButtonItem] = []
/// Returns a flatMap of all the items in each of the UIStackViews
public var items: [InputBarButtonItem] {
return [leftStackViewItems, rightStackViewItems, bottomStackViewItems, nonStackViewItems].flatMap { $0 }
}
// MARK: - Auto-Layout Management
private var textViewLayoutSet: NSLayoutConstraintSet?
private var textViewHeightAnchor: NSLayoutConstraint?
private var leftStackViewLayoutSet: NSLayoutConstraintSet?
private var rightStackViewLayoutSet: NSLayoutConstraintSet?
private var bottomStackViewLayoutSet: NSLayoutConstraintSet?
private var previousIntrinsicContentSize: CGSize?
// MARK: - Initialization
public convenience init() {
self.init(frame: .zero)
}
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Setup
open func setup() {
backgroundColor = .inputBarGray
autoresizingMask = [.flexibleHeight]
setupSubviews()
setupConstraints()
setupObservers()
}
private func setupSubviews() {
addSubview(blurView)
addSubview(inputTextView)
addSubview(leftStackView)
addSubview(rightStackView)
addSubview(bottomStackView)
addSubview(separatorLine)
setStackViewItems([sendButton], forStack: .right, animated: false)
}
private func setupConstraints() {
separatorLine.addConstraints(topAnchor, left: leftAnchor, right: rightAnchor, heightConstant: 0.5)
blurView.fillSuperview()
textViewLayoutSet = NSLayoutConstraintSet(
top: inputTextView.topAnchor.constraint(equalTo: topAnchor, 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)
).activate()
textViewHeightAnchor = inputTextView.heightAnchor.constraint(equalToConstant: maxHeight)
leftStackViewLayoutSet = NSLayoutConstraintSet(
top: inputTextView.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)
).activate()
rightStackViewLayoutSet = NSLayoutConstraintSet(
top: inputTextView.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)
).activate()
bottomStackViewLayoutSet = NSLayoutConstraintSet(
top: bottomStackView.topAnchor.constraint(equalTo: inputTextView.bottomAnchor),
bottom: bottomStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding.bottom),
left: bottomStackView.leftAnchor.constraint(equalTo: leftAnchor, constant: padding.left),
right: bottomStackView.rightAnchor.constraint(equalTo: rightAnchor, constant: -padding.right)
).activate()
}
private func updateViewContraints() {
textViewLayoutSet?.top?.constant = padding.top
leftStackViewLayoutSet?.top?.constant = padding.top
leftStackViewLayoutSet?.left?.constant = padding.left
rightStackViewLayoutSet?.top?.constant = padding.top
rightStackViewLayoutSet?.right?.constant = -padding.right
bottomStackViewLayoutSet?.left?.constant = padding.left
bottomStackViewLayoutSet?.right?.constant = -padding.right
bottomStackViewLayoutSet?.bottom?.constant = -padding.bottom
}
private func setupObservers() {
NotificationCenter.default.addObserver(self,
selector: #selector(MessageInputBar.orientationDidChange),
name: .UIDeviceOrientationDidChange, object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(MessageInputBar.textViewDidChange),
name: .UITextViewTextDidChange, object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(MessageInputBar.textViewDidBeginEditing),
name: .UITextViewTextDidBeginEditing, object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(MessageInputBar.textViewDidEndEditing),
name: .UITextViewTextDidEndEditing, object: nil)
}
// MARK: - Layout Helper Methods
/// Layout the given UIStackView's
///
/// - Parameter positions: The UIStackView's to layout
public func layoutStackViews(_ positions: [UIStackViewPosition] = [.left, .right, .bottom]) {
for position in positions {
switch position {
case .left:
leftStackView.setNeedsLayout()
leftStackView.layoutIfNeeded()
case .right:
rightStackView.setNeedsLayout()
rightStackView.layoutIfNeeded()
case .bottom:
bottomStackView.setNeedsLayout()
bottomStackView.layoutIfNeeded()
}
}
}
/// Performs layout changes over the main thread
///
/// - Parameters:
/// - animated: If the layout should be animated
/// - animations: Code
internal func performLayout(_ animated: Bool, _ animations: @escaping () -> Void) {
textViewLayoutSet?.deactivate()
leftStackViewLayoutSet?.deactivate()
rightStackViewLayoutSet?.deactivate()
bottomStackViewLayoutSet?.deactivate()
if animated {
DispatchQueue.main.async {
UIView.animate(withDuration: 0.3, animations: animations)
}
} else {
UIView.performWithoutAnimation { animations() }
}
textViewLayoutSet?.activate()
leftStackViewLayoutSet?.activate()
rightStackViewLayoutSet?.activate()
bottomStackViewLayoutSet?.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
///
/// - Parameters:
/// - items: New UIStackView arranged views
/// - position: The targeted UIStackView
/// - animated: If the layout should be animated
open func setStackViewItems(_ items: [InputBarButtonItem], forStack position: UIStackViewPosition, animated: Bool) {
func setNewItems() {
switch position {
case .left:
leftStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
leftStackViewItems = items
leftStackViewItems.forEach {
$0.messageInputBar = self
$0.parentStackViewPosition = position
leftStackView.addArrangedSubview($0)
}
leftStackView.layoutIfNeeded()
case .right:
rightStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
rightStackViewItems = items
rightStackViewItems.forEach {
$0.messageInputBar = self
$0.parentStackViewPosition = position
rightStackView.addArrangedSubview($0)
}
rightStackView.layoutIfNeeded()
case .bottom:
bottomStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
bottomStackViewItems = items
bottomStackViewItems.forEach {
$0.messageInputBar = self
$0.parentStackViewPosition = position
bottomStackView.addArrangedSubview($0)
}
bottomStackView.layoutIfNeeded()
}
}
performLayout(animated) {
setNewItems()
}
}
/// Sets the leftStackViewWidthConstant
///
/// - Parameters:
/// - newValue: New widthAnchor constant
/// - animated: If the layout should be animated
open func setLeftStackViewWidthConstant(to newValue: CGFloat, animated: Bool) {
performLayout(animated) {
self.leftStackViewWidthContant = newValue
self.layoutStackViews([.left])
self.layoutIfNeeded()
}
}
/// Sets the rightStackViewWidthConstant
///
/// - Parameters:
/// - newValue: New widthAnchor constant
/// - animated: If the layout should be animated
open func setRightStackViewWidthConstant(to newValue: CGFloat, animated: Bool) {
performLayout(animated) {
self.rightStackViewWidthContant = newValue
self.layoutStackViews([.right])
self.layoutIfNeeded()
}
}
// MARK: - Notifications/Hooks
@objc open func orientationDidChange() {
invalidateIntrinsicContentSize()
}
@objc open func textViewDidChange() {
let trimmedText = inputTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
sendButton.isEnabled = !trimmedText.isEmpty
inputTextView.placeholderLabel.isHidden = !inputTextView.text.isEmpty
items.forEach { $0.textViewDidChangeAction(with: inputTextView) }
delegate?.messageInputBar(self, textViewTextDidChangeTo: trimmedText)
invalidateIntrinsicContentSize()
}
@objc open func textViewDidBeginEditing() {
self.items.forEach { $0.keyboardEditingBeginsAction() }
}
@objc open func textViewDidEndEditing() {
self.items.forEach { $0.keyboardEditingEndsAction() }
}
// MARK: - User Actions
open func didSelectSendButton() {
delegate?.messageInputBar(self, didPressSendButtonWith: inputTextView.text)
textViewDidChange()
}
}