Skip to content

Instantly share code, notes, and snippets.

@bguidolim
Created November 9, 2019 01:59
Show Gist options
  • Save bguidolim/ba9339d5f13803809d8e24376e69d630 to your computer and use it in GitHub Desktop.
Save bguidolim/ba9339d5f13803809d8e24376e69d630 to your computer and use it in GitHub Desktop.
Link Preview implementation using MessageKit
//
// ChatCollectionViewFlowLayout.swift
// Engage
//
// Created by Bruno Guidolim on 04.08.19.
// Copyright © 2019 COYO GmbH. All rights reserved.
//
import MessageKit
internal final class ChatCollectionViewFlowLayout: MessagesCollectionViewFlowLayout {
lazy var systemMessageCalculator: ChatSystemMessageCalculator = .init(layout: self)
lazy var linkMessageCalculator: LinkMessageSizeCalculator = .init(layout: self)
override init() {
super.init()
setMessageIncomingMessagePadding(UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 30))
setMessageOutgoingMessagePadding(UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 8))
setMessageOutgoingAvatarSize(.zero)
textMessageSizeCalculator.messageLabelFont = UIFont.bodyFont
linkMessageCalculator.messageLabelFont = UIFont.bodyFont
let labelInsets: UIEdgeInsets = .init(top: 7, left: 12, bottom: 7, right: 12)
textMessageSizeCalculator.incomingMessageLabelInsets = labelInsets
textMessageSizeCalculator.outgoingMessageLabelInsets = labelInsets
linkMessageCalculator.incomingMessageLabelInsets = labelInsets
linkMessageCalculator.outgoingMessageLabelInsets = labelInsets
setMessageIncomingAvatarPosition(.init(vertical: .messageBottom))
let outgoingBottomLabelAlignment: LabelAlignment = .init(textAlignment: .right,
textInsets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 19))
setMessageOutgoingMessageBottomLabelAlignment(outgoingBottomLabelAlignment)
let incomingLabelAlignment: LabelAlignment = .init(textAlignment: .left,
textInsets: UIEdgeInsets(top: 0, left: 50, bottom: 0, right: 0))
setMessageIncomingMessageBottomLabelAlignment(incomingLabelAlignment)
setMessageIncomingMessageTopLabelAlignment(incomingLabelAlignment)
minimumLineSpacing = 2
sectionInset = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func messageSizeCalculators() -> [MessageSizeCalculator] {
return super.messageSizeCalculators() + [systemMessageCalculator, linkMessageCalculator]
}
}
//
// LinkMessageCell.swift
// Engage
//
// Created by Bruno Guidolim on 04.08.19.
// Copyright © 2019 COYO GmbH. All rights reserved.
//
import MessageKit
internal final class LinkMessageCell: TextMessageCell {
private let linkPreviewView: LinkPreviewView = .init()
private var linkURL: URL?
override func configure(with message: MessageType,
at indexPath: IndexPath,
and messagesCollectionView: MessagesCollectionView) {
guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else {
return
}
let textColor: UIColor = displayDelegate.textColor(for: message, at: indexPath, in: messagesCollectionView)
linkPreviewView.titleLabel.textColor = textColor
linkPreviewView.teaserLabel.textColor = textColor
linkPreviewView.domainLabel.textColor = textColor
guard case .custom(let object) = message.kind,
let customType = object as? ChatMessageCustomType,
let chatMessage = message as? ChatMessage else {
preconditionFailure("Was not possible to unwrap the custom type.")
}
switch customType {
case .link(let messageText, let linkURL, let linkPreview):
let newChatMessage: ChatMessage = .init(sender: chatMessage.sender,
messageId: chatMessage.messageId,
sentDate: chatMessage.sentDate,
kind: .text(messageText),
updatedID: chatMessage.updatedID)
super.configure(with: newChatMessage, at: indexPath, and: messagesCollectionView)
if linkPreviewView.superview == nil {
linkPreviewView.translatesAutoresizingMaskIntoConstraints = false
messageContainerView.addSubview(linkPreviewView)
linkPreviewView.leadingAnchor.constraint(equalTo: messageContainerView.leadingAnchor,
constant: messageLabel.textInsets.left).isActive = true
linkPreviewView.trailingAnchor.constraint(equalTo: messageContainerView.trailingAnchor,
constant: messageLabel.textInsets.right * -1).isActive = true
linkPreviewView.bottomAnchor.constraint(equalTo: messageContainerView.bottomAnchor,
constant: messageLabel.textInsets.bottom * -1).isActive = true
}
if let linkPreview: ChatLinkPreview = linkPreview, !linkPreview.teaser.isEmptyOrNil {
linkPreviewView.titleLabel.text = linkPreview.title
linkPreviewView.teaserLabel.text = linkPreview.teaser
linkPreviewView.domainLabel.text = linkPreview.domain?.lowercased()
// Images for link preview are always temporary on the server, so we try to keep this image forever in the cache
linkPreviewView.imageView.setImage(from: linkPreview.imageURL,
placeholder: UIImage(named: "link"),
options: [.diskCacheExpiration(.never),
.memoryCacheExpiration(.never)])
self.linkURL = linkURL
}
default:
fatalError("Invalid type for this cell.")
}
}
override func prepareForReuse() {
super.prepareForReuse()
linkPreviewView.titleLabel.text = nil
linkPreviewView.teaserLabel.text = nil
linkPreviewView.domainLabel.text = nil
linkPreviewView.imageView.image = nil
linkURL = nil
}
override func handleTapGesture(_ gesture: UIGestureRecognizer) {
let touchLocation: CGPoint = convert(gesture.location(in: self), to: linkPreviewView)
if linkPreviewView.bounds.contains(touchLocation), let url: URL = linkURL {
delegate?.didSelectURL(url)
return
}
super.handleTapGesture(gesture)
}
}
fileprivate final class LinkPreviewView: UIView {
lazy var imageView: UIImageView = {
let imageView: UIImageView = .init()
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
imageView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
imageView.topAnchor.constraint(equalTo: topAnchor).isActive = true
imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 1).isActive = true
imageView.widthAnchor.constraint(equalToConstant: LinkMessageSizeCalculator.ImageViewSize).isActive = true
imageView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor).isActive = true
return imageView
}()
lazy var titleLabel: UILabel = {
let label: UILabel = .init()
label.numberOfLines = 0
label.font = .caption1SemiBoldFont
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
lazy var teaserLabel: UILabel = {
let label: UILabel = .init()
label.numberOfLines = 0
label.font = .caption2Font
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
lazy var domainLabel: UILabel = {
let label: UILabel = .init()
label.numberOfLines = 0
label.font = .caption2SemiBoldFont
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var contentView: UIView = {
let view: UIView = .init(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
view.topAnchor.constraint(equalTo: topAnchor).isActive = true
view.leadingAnchor.constraint(equalTo: imageView.trailingAnchor,
constant: LinkMessageSizeCalculator.ImageViewMargin).isActive = true
view.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
return view
}()
init() {
super.init(frame: .zero)
contentView.addSubview(titleLabel)
contentView.addSubview(teaserLabel)
contentView.addSubview(domainLabel)
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
teaserLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
teaserLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3).isActive = true
teaserLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
teaserLabel.setContentHuggingPriority(.init(249), for: .vertical)
domainLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
domainLabel.topAnchor.constraint(equalTo: teaserLabel.bottomAnchor, constant: 3).isActive = true
domainLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
domainLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
//
// LinkMessageSizeCalculator.swift
// Engage
//
// Created by Bruno Guidolim on 04.08.19.
// Copyright © 2019 COYO GmbH. All rights reserved.
//
import MessageKit
internal final class LinkMessageSizeCalculator: TextMessageSizeCalculator {
private typealias Message = (message: String, linkURL: URL, linkPreview: ChatLinkPreview?)
static let ImageViewSize: CGFloat = 60
static let ImageViewMargin: CGFloat = 8
private var chatLinkPreview: ChatLinkPreview?
override func messageContainerMaxWidth(for message: MessageType) -> CGFloat {
return chatLinkPreview == nil ?
super.messageContainerMaxWidth(for: message) :
(layout?.collectionView?.bounds.width ?? 0.0) * 0.75
}
override func messageContainerSize(for message: MessageType) -> CGSize {
let messageTuple: Message = unwrapMessage(message)
self.chatLinkPreview = messageTuple.linkPreview
guard let chatMessage = message as? ChatMessage else { return .zero }
let dummyMessage: ChatMessage = .init(sender: message.sender,
messageId: message.messageId,
sentDate: message.sentDate,
kind: .text(messageTuple.message),
updatedID: chatMessage.updatedID)
var containerSize: CGSize = super.messageContainerSize(for: dummyMessage)
guard let linkPreview = chatLinkPreview, !linkPreview.teaser.isEmptyOrNil else {
return containerSize
}
let labelInsets: UIEdgeInsets = messageLabelInsets(for: message)
let maxWidth: CGFloat = messageContainerMaxWidth(for: message)
containerSize.width = max(containerSize.width, maxWidth)
let minHeight: CGFloat = containerSize.height + LinkMessageSizeCalculator.ImageViewSize
let previewMaxWidth: CGFloat = containerSize.width - (LinkMessageSizeCalculator.ImageViewSize + LinkMessageSizeCalculator.ImageViewMargin + labelInsets.horizontal)
calculateContainerSize(with: NSAttributedString(string: linkPreview.title ?? "", attributes: [.font: UIFont.caption1SemiBoldFont]),
containerSize: &containerSize,
maxWidth: previewMaxWidth)
calculateContainerSize(with: NSAttributedString(string: linkPreview.teaser ?? "", attributes: [.font: UIFont.caption2Font]),
containerSize: &containerSize,
maxWidth: previewMaxWidth)
calculateContainerSize(with: NSAttributedString(string: linkPreview.domain ?? "", attributes: [.font: UIFont.caption2SemiBoldFont]),
containerSize: &containerSize,
maxWidth: previewMaxWidth)
containerSize.height = max(minHeight, containerSize.height) + labelInsets.vertical
return containerSize
}
private func calculateContainerSize(with attibutedString: NSAttributedString, containerSize: inout CGSize, maxWidth: CGFloat) {
if attibutedString.string.isEmpty {
return
}
let size: CGSize = attibutedString.labelSize(considering: maxWidth)
containerSize.height += size.height
}
private func messageLabelInsets(for message: MessageType) -> UIEdgeInsets {
let dataSource: MessagesDataSource = messagesLayout.messagesDataSource
let isFromCurrentSender: Bool = dataSource.isFromCurrentSender(message: message)
return isFromCurrentSender ? outgoingMessageLabelInsets : incomingMessageLabelInsets
}
private func unwrapMessage(_ message: MessageType) -> Message {
guard case .custom(let object) = message.kind,
let customType = object as? ChatMessageCustomType,
case let .link(attributes) = customType else {
preconditionFailure("Was not possible to unwrap the custom type.")
}
return (attributes.message, attributes.linkURL, attributes.linkPreview)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment