Creating a collaborative photo gallery with SharePlay
Build a shared photo gallery by using SharePlay to synchronize images among participants.
Overview
SharePlay allows multiple people to collaborate and share content such as photos or files with each other.
This sample code project uses SharePlay to create a shared photo gallery containing images. Participants in a FaceTime call, or using nearby sharing on visionOS, can upload images to share with each other. All participants in the session see the changes in real time.
While running the app, Control-clicking or long-pressing on an image gives you the option to remove an image or open it with Quick Look as a preview. When you open a QuickLook preview, the window opens only for you.
SharePlay allows you to share different types of data among participants. Because this sample sends larger amounts of data, like images and files, it uses GroupSessionJournal to share data. For smaller amounts of data or more time-sensitive messages, like adding a comment to someone’s shared image, use GroupSessionMessenger. For more information, see Synchronizing data during a SharePlay activity.
For a SharePlay sample code project that uses GroupSessionMessenger, see Implementing SharePlay for immersive spaces in visionOS.
Define a custom group activity
The app creates a GroupActivity called PhotoShareActivity that defines the photo-sharing experience and allows you to start an activity-related SharePlay session. The activity provides metadata, like the title and type of the activity, which appears in the system UI when people share an experience.
struct PhotoShareActivity: GroupActivity, Transferable {
/// Configures the displayed SharePlay activity metadata.
var metadata: GroupActivityMetadata = {
var metadata = GroupActivityMetadata()
metadata.type = .generic
metadata.title = "Share Photos"
metadata.subtitle = "Collaborate on a shared photo album."
return metadata
}()
}For more information, see Defining your app’s SharePlay activities.
Start a SharePlay session
The app’s toolbar includes a button to start a SharePlay session, but the implementation differs by platform.
For iOS and macOS apps, the button is a ShareLink, which shows the share sheet when tapped or clicked. If a FaceTime call is active, it allows you to start a new SharePlay session; otherwise you can start a new FaceTime call:
ShareLink(item: PhotoShareActivity(), preview: SharePreview("Share photos")) {
Image(systemName: "shareplay")
}In visionOS, the app uses a custom button that starts the activity immediately by calling activate() on PhotoShareActivity if the person is on a FaceTime call. Otherwise, the system shows the share sheet to help you start a FaceTime call.
Button {
Task { @concurrent in
do {
// Directly start the group activity.
if !(try await PhotoShareActivity().activate()) {
logger.log("The session wasn't created, or it was handed off to an Apple TV.")
}
} catch {
// The session failed to start.
logger.log("Failed to create a session: \(error.localizedDescription)")
}
}
} label: {
Image(systemName: "shareplay")
}visionOS apps also allow people to start an activity from the Share Window menu in the window bar of an app while in a FaceTime call. To donate the activity to the menu, the main view includes a ShareLink, which is hidden from the UI with the hidden() modifier.
ShareLink(item: PhotoShareActivity(), preview: SharePreview("Share Images"))
.hidden()For more information, see Presenting SharePlay activities from your app’s UI.
Join and manage the activity
When someone starts a SharePlay session, the system creates a GroupSession for the custom activity to synchronize content between devices. To join an existing activity, the observeGroupSessions function continuously monitors for new PhotoShareActivity sessions. When one becomes available, the function starts observing the session state and creates a SessionController to manage the session. To ensure that locally uploaded images are shared when the activity begins, the function also uploads those images to the GroupSessionJournal object:
private func observeGroupSessions() async {
// When a new `GroupSession` of `PhotoShareActivity` is available, set up a `SessionController`
// to manage the session and join it.
for await session in PhotoShareActivity.sessions() {
let sessionController = await SessionController(session, appModel: self)
self.sessionController = sessionController
// Create a task on the same actor to observe the group session state and clear the
// session controller when the group session invalidates.
Task.immediate(name: "session state observation") {
for await state in session.$state.values {
guard self.sessionController?.session.id == session.id else {
return
}
if case .invalidated = state {
self.sessionController = nil
return
}
}
}
// Share existing finished images when the session starts.
await shareExistingImages(to: sessionController)
}
}The sample creates a SessionController to set up the GroupSession with the photo-sharing activity, create the journal object to share images, and handle state synchronization. The session controller then starts observing incoming updates on the GroupSessionJournal before adding the person to the session.
init(_ groupSession: GroupSession<PhotoShareActivity>, appModel: AppModel) async {
// Keep track of the current session.
session = groupSession
// Create a journal using the incoming session.
groupSessionJournal = GroupSessionJournal(session: session)
self.appModel = appModel
// Monitor for incoming attachments.
observeIncomingImagesTask = observeJournalAttachments()
// Join the session.
session.join()
}Because people probably won’t join the call at the same time, the app needs to handle bringing people up to date. GroupSessionJournal handles this for you to mitigate re-uploading large attachments. For more information on handling this if you’re syncing other data, see Joining and managing a shared activity. For an example, see Implementing SharePlay for immersive spaces in visionOS or Building a guessing game for visionOS.
Upload images
The app uses a PhotosPicker to allow people to upload images from their photo library. For more information on using PhotosPicker in SwiftUI, see Bringing Photos picker to your SwiftUI app.
The sample app represents images chosen from the photo picker as instances of DisplayImage, a Transferable structure that stores image data and metadata like the date created and the owner.
When someone chooses an image from the photo picker, the app loads a Data object from the PhotosPickerItem that represents the image and updates the local images array with that data:
if let loadedData = try await photoPickerItem.loadTransferable(type: Data.self) {
// Data loaded successfully, create the `DisplayImage`.
var displayImage = DisplayImage(owner: self.name, id: imageID)
displayImage.imageData = loadedData
self.localImagesByIdentifier[imageID] = displayImage
// Clean up the task when complete.
imageLoadingTasks.removeValue(forKey: imageID)
// Share the image with other participants, if a session is active.
await sessionController?.addNewImage(displayImage)
}If the sample app has an existing SharePlay session, the method shares the local images with the other participants. If there isn’t an existing SharePlay session, the UI uses the image data in localImagesByIdentifier and initializes an Image using that data.
Synchronize files between participants
To synchronize the images between participants, the app creates a GroupSessionJournal object to add and removes images from the shared session. For an overview of GroupSessionJournal, watch the WWDC23 session Share files with SharePlay.
When someone chooses an image with the photos picker during a SharePlay session, the app syncs it with other participants by adding it to the group’s journal using add(_:):
func addNewImage(_ image: DisplayImage) async {
do {
loadingAttachmentCount += 1
defer { loadingAttachmentCount -= 1 }
// Add the attachment.
receivedImages[image.id] = image
let newAttachment = try await groupSessionJournal.add(image, metadata: image.id)
attachments[image.id] = newAttachment
} catch {
self.logger.log("Error while adding file: \(error.localizedDescription)")
}
}When another participant adds or removes an attachment, the journal updates the attachments property. The SessionController configures the following task when initialized to listen for asynchronous updates to the attachments and executes the task to load them into the app:
private func observeJournalAttachments() -> Task<Void, Error> {
return Task {
for await attachments in groupSessionJournal.attachments {
await processIncomingAttachments(attachments)
}
}
}The processIncomingAttachments method loads the contents of the incoming attachment as a DisplayImage by calling load(_:) on it, and specifying the DisplayImage type. The method saves the data in a dictionary, which the UI iterates over and uses to initialize an Image to show to all participants:
private func loadAttachment(_ attachment: GroupSessionJournal.Attachment) async {
do {
loadingAttachmentCount += 1
defer { loadingAttachmentCount -= 1 }
// Load the image.
let displayImage = try await attachment.load(DisplayImage.self)
logger.log("Successfully loaded attachment data for: \(displayImage.id)")
// Store the attachment reference.
attachments[displayImage.id] = attachment
// Add to received images array.
self.receivedImages[displayImage.id] = displayImage
} catch {
logger.log("Failed to process attachment \(attachment.id): \(error)")
}
}For more information on sending and receiving data, see Synchronizing data during a SharePlay activity.