Working with Attachments/Files
Stream chat allows you to add attachments to a message. There is built-in support for files, images, videos, voice recordings, giphy and URL preview attachments. It is also possible to have your own type of attachment and to customize how built-in attachments are rendered. Custom attachments are one of the most useful ways to add application specific content to messages. Examples of custom attachments are: location sharing and workouts.
The type of attachment is defined by the value on the Type
field. All SDKs support these built-in attachment types out-of-the-box:
- attachment type
image
for images - attachment type
video
for videos - attachment type
voiceRecording
for voice recordings - attachment type
giphy
for interactive Giphy attachments (see Slack) - attachment type
file
for files
URL previews can have type image, video or audio depending on the resource that the URL addresses. All attachments view classes are subclasses of the AttachmentViewInjector
class.
All Chat SDKs allow you to customize how built-in types are handled as well as use your own custom attachment types.
How to Customize Built-In Attachments
Built-in attachments are mapped to specific AttachmentViewInjector
classes, the mapping is defined on the Components
class.
Example: change the view class used to render files attachments with a custom class.
Components.default.filesAttachmentInjector = MyCustomAttachmentViewInjector.self
This is the list of Components
's attributes used to map attachments to views
Attribute | Description | Default View Class |
---|---|---|
galleryAttachmentInjector | Single or multiple images and video attachments | GalleryAttachmentViewInjector.self |
linkAttachmentInjector | URL preview attachments | LinkAttachmentViewInjector.self |
giphyAttachmentInjector | Giphy attachments | GiphyAttachmentViewInjector.self |
voiceRecordingAttachmentInjector | Voice Recording attachments | VoiceRecordingAttachmentViewInjector.self |
filesAttachmentInjector | File attachments | FilesAttachmentViewInjector.self |
You can implement MyCustomAttachmentViewInjector
as a subclass of FilesAttachmentViewInjector
or as a subclass of AttachmentViewInjector
.
In both cases you will implement at least these two methods: contentViewDidLayout(options: ChatMessageLayoutOptions)
and contentViewDidUpdateContent
.
To keep this easy to read we are going to create two classes: MyCustomAttachmentViewInjector
and MyCustomAttachmentView
. The latter is your custom attachment view, you can implement it programmatically or with interface builder using XIBs.
import StreamChat
import StreamChatUI
import UIKit
class MyCustomAttachmentViewInjector: AttachmentViewInjector {
let attachmentView = MyCustomAttachmentView()
var message: ChatMessage? {
contentView.content
}
override func contentViewDidLayout(options: ChatMessageLayoutOptions) {
super.contentViewDidLayout(options: options)
contentView.bubbleContentContainer.insertArrangedSubview(attachmentView, at: 0, respectsLayoutMargins: true)
}
override func contentViewDidUpdateContent() {
super.contentViewDidUpdateContent()
attachmentView.fileAttachment = message?.attachments(payloadType: FileAttachmentPayload.self).first
}
}
class MyCustomAttachmentView: UIView {
...
var fileAttachment: ChatMessageFileAttachment? {
didSet {
update()
}
}
func update() {
// Update the UI here when the attachment changes
}
}
The last step is to inform the SDK to use MyCustomAttachmentViewInjector
for file attachments instead of the default one. This is something that you want to do as early as possible in your application life-cycle.
Components.default.filesAttachmentInjector = MyCustomAttachmentViewInjector.self
How to Build a Custom Attachment
Stream chat allows you to create your own types of attachments as well. The steps to follow to add support for a custom attachment type are the following:
- Extend
AttachmentType
to include the custom type - Create a new
AttachmentPayload
struct to handle your custom attachment data - Register the new attachment payload type in
ChatClient
- Create a
typealias
forChatMessageAttachment<AttachmentPayload>
which will be used for the content of the view - Create a new
AttachmentViewInjector
- Configure the SDK to use your view injector class to render custom attachments
Let's assume we want to attach a workout session to a message, the payload of the attachment will look like this:
{
"type": "workout",
"image_url": "https://path.to/some/great/picture.png",
"workout-type": "walk",
"workout-energy-cal": 75500,
"workout-distance-meters": 1412,
"workout-duration-seconds": 840
}
In the code, the payload and attachment type should look something like this:
public extension AttachmentType {
static let workout = Self(rawValue: "workout")
}
public typealias ChatMessageWorkoutAttachment = ChatMessageAttachment<WorkoutAttachmentPayload>
public struct WorkoutAttachmentPayload: AttachmentPayload {
public static var type: AttachmentType = .workout
var imageURL: URL?
var workoutDistanceMeters: Int?
var workoutType: String?
var workoutDurationSeconds: Int?
var workoutEnergyCal: Int?
private enum CodingKeys: String, CodingKey {
case workoutDistanceMeters = "workout-distance-meters"
case workoutType = "workout-type"
case workoutDurationSeconds = "workout-duration-seconds"
case workoutEnergyCal = "workout-energy-cal"
case imageURL = "image_url"
}
}
Here we extended AttachmentType
to include the workout
type and afterwards we introduced a new struct to match the payload data that we expect.
WorkoutAttachmentPayload.type
is used to match message attachments to this struct- All attachment fields are optional, this is highly recommended for all custom data
- We use
CodingKeys
to map JSON field names to struct fields
Then, you should register your custom attachment type when creating the ChatClient
, example:
let client = ChatClient(config: config)
client.registerAttachment(WorkoutAttachmentPayload.self)
The ChatClient.registerAttachment()
is only available after the 4.42.0 release. This one was added to make sure that editing custom attachments is also supported.
Let's now create a custom view injector to handle the workout attachment view.
import StreamChat
import StreamChatUI
import UIKit
class WorkoutAttachmentView: UIView, ComponentsProvider {
var workoutAttachment: ChatMessageWorkoutAttachment? {
didSet {
update()
}
}
lazy var imageView = UIImageView()
lazy var distanceLabel = UILabel()
lazy var durationLabel = UILabel()
lazy var energyLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
func setupView() {
distanceLabel.backgroundColor = .yellow
distanceLabel.numberOfLines = 0
durationLabel.backgroundColor = .green
durationLabel.numberOfLines = 0
energyLabel.backgroundColor = .red
energyLabel.numberOfLines = 0
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
let container = ContainerStackView(arrangedSubviews: [distanceLabel, durationLabel, energyLabel])
container.translatesAutoresizingMaskIntoConstraints = false
container.distribution = .equal
addSubview(container)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: container.topAnchor),
container.leadingAnchor.constraint(equalTo: leadingAnchor),
container.trailingAnchor.constraint(equalTo: trailingAnchor),
container.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
func update() {
if let attachment = workoutAttachment {
components.imageLoader.loadImage(into: imageView, from: attachment.imageURL)
distanceLabel.text = "you walked \(attachment.workoutDistanceMeters ?? 0) meters!"
durationLabel.text = "it took you \(attachment.workoutDurationSeconds ?? 0) seconds!"
energyLabel.text = "you burned \(attachment.workoutEnergyCal ?? 0) calories!"
}
}
}
open class WorkoutAttachmentViewInjector: AttachmentViewInjector {
let workoutView = WorkoutAttachmentView()
var message: ChatMessage? {
contentView.content
}
override open func contentViewDidLayout(options: ChatMessageLayoutOptions) {
super.contentViewDidLayout(options: options)
contentView.bubbleContentContainer.insertArrangedSubview(workoutView, at: 0, respectsLayoutMargins: true)
}
override open func contentViewDidUpdateContent() {
super.contentViewDidUpdateContent()
workoutView.workoutAttachment = message?.attachments(payloadType: WorkoutAttachmentPayload.self).first
}
}
The WorkoutAttachmentView
class is where all layout and content logic happens. In contentViewDidLayout
we add WorkoutAttachmentView
as a subview of bubbleContentContainer
using insertArrangedSubview
. More information about layout customizations is available here. The last interesting bit happens in contentViewDidUpdateContent
, where we use the attachments
method to retrieve all attachments for these messages with type WorkoutAttachmentPayload
and then pick the first one. This allows us to have the type we defined earlier as the content to render in our custom view.
Now that we have data and view ready we only need to configure the SDK to use WorkoutAttachmentViewInjector
for workout attachments. This is done by changing the default AttachmentViewCatalog
with our own.
class MyAttachmentViewCatalog: AttachmentViewCatalog {
override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? {
guard message.attachmentCounts.keys.contains(.workout) else {
return super.attachmentViewInjectorClassFor(message: message, components: components)
}
return WorkoutAttachmentViewInjector.self
}
}
/// Config the SDK to use our attachment view catalog
Components.default.attachmentViewCatalog = MyAttachmentViewCatalog.self
The AttachmentViewCatalog
class is used to pick the AttachmentViewInjector
for a message, in our case we only need to check if the message contains a workout attachment.
In case you want your attachments to be rendered along with other types of attachments, you need to register your custom injector to the MixedAttachmentViewInjector
registry like so:
Components.default.mixedAttachmentInjector.register(.workout, with: WorkoutAttachmentViewInjector.self)
Then, you need to change your Catalog implementation to return the Mixed injector in case there are multiple types of attachments:
class MyAttachmentViewCatalog: AttachmentViewCatalog {
override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? {
let hasMultipleTypesOfAttachments = message.attachmentCounts.keys.count > 1
if message.attachmentCounts.keys.contains(.workout) {
if hasMultipleTypesOfAttachments {
return MixedAttachmentViewInjector.self
}
return WorkoutAttachmentViewInjector.self
}
return super.attachmentViewInjectorClassFor(message: message, components: components)
}
}
If needed you can send a message with a workout attachment directly from Swift just to test that everything works correctly:
let controller = client.channelController(for: ChannelId(type: .messaging, id: "my-test-channel"))
let attachment = WorkoutAttachmentPayload(imageURL: URL(string: "https://path.to/some/great/picture.png"), workoutDistanceMeters: 150, workoutDurationSeconds: 42, workoutEnergyCal: 1000)
controller.createNewMessage(text: "work-out-test", attachments: [.init(payload: attachment)])
In case you need to interact with your custom attachment, there are a couple of steps required:
- Create a delegate for your custom attachment view which extends from
ChatMessageContentViewDelegate
. - Create a custom
ChatMessageListVC
if you haven't already, and make it conform to the delegate created in step 1. - Change your custom injector and add a tap gesture recognizer to your custom view. The delegate can be called by accessing
contentView.delegate
and casting it to your custom delegate.
Below is the full example on how to add a interaction to the custom workout attachment:
// Step 1
protocol WorkoutAttachmentViewDelegate: ChatMessageContentViewDelegate {
func didTapOnWorkoutAttachment(
_ attachment: ChatMessageWorkoutAttachment
)
}
// Step 2
class CustomChatMessageListVC: ChatMessageListVC, WorkoutAttachmentViewDelegate {
func didTapOnWorkoutAttachment(_ attachment: ChatMessageWorkoutAttachment) {
// For example, here you can present a view controller to display the workout
let workoutViewController = WorkoutViewController(workout: attachment)
navigationController?.pushViewController(workoutViewController, animated: true)
}
}
// Step 3
class WorkoutAttachmentViewInjector: AttachmentViewInjector {
let workoutView = WorkoutAttachmentView()
override open func contentViewDidLayout(options: ChatMessageLayoutOptions) {
super.contentViewDidLayout(options: options)
contentView.bubbleContentContainer.insertArrangedSubview(workoutView, at: 0, respectsLayoutMargins: true)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapOnWorkoutAttachment))
workoutView.addGestureRecognizer(tapGestureRecognizer)
}
override open func contentViewDidUpdateContent() {
super.contentViewDidUpdateContent()
workoutView.workoutAttachment = attachments(payloadType: WorkoutAttachmentPayload.self).first
}
@objc func handleTapOnWorkoutAttachment() {
guard let workoutAttachmentDelegate = contentView.delegate as? WorkoutAttachmentViewDelegate else {
return
}
workoutAttachmentDelegate.didTapOnWorkoutAttachment(workoutView.content)
}
}
Finally, don't forget to assign the custom message list if you haven't yet:
Components.default.messageListVC = CustomChatMessageListVC.self
Quoted Message View Preview
A preview of the custom attachment view while a message containing such attachment is being quoted, needs to be provided as well.
In order to do this, you need to subclass the QuotedChatMessageView
and provide your own implementation for the custom attachment type.
For example, let's create a CustomQuotedMessageView
with the following implementation.
class CustomQuotedMessageView: QuotedChatMessageView {
override func setAttachmentPreview(for message: ChatMessage) {
if let customPayload = message.attachments(payloadType: WorkoutAttachmentPayload.self).first?.payload {
attachmentPreviewView.contentMode = .scaleAspectFit
attachmentPreviewView.image = appearance.images.fileFallback
if textView.text.isEmpty {
textView.text = customPayload.workoutType
}
}
return super.setAttachmentPreview(for: message)
}
}
In the subclass, we are overriding the implementation of the setAttachmentPreview
method. We are checking if the message contains a custom attachment of type WorkoutAttachmentPayload
. If it does, we are providing an image and a text available from the message payload.
Finally, you need to inject your custom implementation of the CustomQuotedMessageView
in our Components
's property quotedMessageView
.
Components.default.quotedMessageView = CustomQuotedMessageView.self
Tracking custom attachment upload progress
In the previous examples, we assumed that you already have the custom attachment remote URL available. But, you can also upload the custom attachment through the Stream's SDK and observe the uploading progress.
The simplest approach is to upload the attachment through the channelController.uploadAttachment()
. Using this function you can observe the progress, but the limitation is that at this time, the message is not yet sent, so this approach is good if you want to show the upload progress before creating the message. Here is an example:
let controller = client.channelController(for: ChannelId(type: .messaging, id: "my-test-channel"))
controller.uploadAttachment(
localFileURL: localFileUrlFromUsersDevice,
type: .workout,
progress: { value in
// Update here the upload progress
someProgressView.update(with: value)
},
completion: { [weak self] result in
// Don't forget to handle the error case
guard let uploadedAttachment = try? result.get() else { return }
let attachment = WorkoutAttachmentPayload(
imageURL: uploadedAttachment.remoteURL,
workoutDistanceMeters: 150,
workoutDurationSeconds: 42,
workoutEnergyCal: 1000
)
self?.controller.createNewMessage(text: "work-out-test", attachments: [.init(payload: attachment)])
}
)
In case you want to show the upload progress in the message cell, like it is shown in the Stream's native components, there are a couple of steps to go through:
- Implement an
UploadedAttachmentPostProcessor
and inject it inChatClientConfig
. This is needed to update the remote URL once the attachment is successfully uploaded. - Create the custom attachment payload using
AnyAttachmentPayload(localFileURL:customPayload)
initializer and create the message instantly. - Observe the
AttachmentUploadingState
in your custom view. The message is updated whenever the attachment progress changes, and the progress can be read by theattachment.uploadingState
property.
Below is a full example on how to show the uploading progress in the message cell:
// Step 1
public class CustomUploadedAttachmentPostProcessor: UploadedAttachmentPostProcessor {
// This component is a helper class to update the payload of type-erased attachments.
let attachmentUpdater = AnyAttachmentUpdater()
public func process(uploadedAttachment: UploadedAttachment) -> UploadedAttachment {
var attachment = uploadedAttachment.attachment
attachmentUpdater.update(&attachment, forPayload: WorkoutAttachmentPayload.self) { payload in
payload.imageURL = uploadedAttachment.remoteURL
}
return UploadedAttachment(attachment: attachment, remoteURL: uploadedAttachment.remoteURL)
}
}
// Don't forget to inject it in the ChatClientConfig when creating your ChatClient
config.uploadedAttachmentPostProcessor = CustomUploadedAttachmentPostProcessor()
// Step 2
let controller = client.channelController(for: ChannelId(type: .messaging, id: "my-test-channel"))
let workoutPayload = WorkoutAttachmentPayload(
imageURL: nil,
workoutDistanceMeters: 150,
workoutDurationSeconds: 42,
workoutEnergyCal: 1000
)
let workoutAttachment = AnyAttachmentPayload(
localFileURL: localFileUrlFromUsersDevice,
customPayload: workoutPayload
)
controller.createNewMessage(text: "work-out-test", attachments: [.init(payload: workoutAttachment)])
// Step 3
class WorkoutAttachmentView: UIView, ComponentsProvider {
var workoutAttachment: ChatMessageWorkoutAttachment? {
didSet {
update()
}
}
lazy var uploadingOverlayView = UploadingOverlayView()
lazy var imageView = UIImageView()
lazy var distanceLabel = UILabel()
lazy var durationLabel = UILabel()
lazy var energyLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
func setupView() {
distanceLabel.backgroundColor = .yellow
distanceLabel.numberOfLines = 0
durationLabel.backgroundColor = .green
durationLabel.numberOfLines = 0
energyLabel.backgroundColor = .red
energyLabel.numberOfLines = 0
uploadingOverlayView.translatesAutoresizingMaskIntoConstraints = false
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
addSubview(imageView)
addSubview(uploadingOverlayView)
let container = ContainerStackView(arrangedSubviews: [distanceLabel, durationLabel, energyLabel])
container.translatesAutoresizingMaskIntoConstraints = false
container.distribution = .equal
addSubview(container)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: container.topAnchor),
uploadingOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
uploadingOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
uploadingOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
uploadingOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
container.leadingAnchor.constraint(equalTo: leadingAnchor),
container.trailingAnchor.constraint(equalTo: trailingAnchor),
container.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
func update() {
if let attachment = workoutAttachment {
// The uploadingOverlayView is used to show the uploading progress but you can use your own view.
uploadingOverlayView.content = attachment.uploadingState
components.imageLoader.loadImage(into: imageView, from: attachment.imageURL)
distanceLabel.text = "you walked \(attachment.workoutDistanceMeters ?? 0) meters!"
durationLabel.text = "it took you \(attachment.workoutDurationSeconds ?? 0) seconds!"
energyLabel.text = "you burned \(attachment.workoutEnergyCal ?? 0) calories!"
}
}
}
For step 2, you can put this snippet in your custom composer when the user inserts a new attachment. You can read here on how to customize the message composer to support custom attachments.
As you can see, in the Step 3, the custom view is pretty much the same as the one from the previous examples, we only added an UploadingOverlayView
to show the progress of the attachment. But, in order for this to work, it is required to do Step 1 and Step 2.