mirror of
https://github.com/MessageKit/MessageKit.git
synced 2026-02-06 19:03:19 +00:00
466 lines
18 KiB
Swift
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()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
}
|