Stream's Activity Feeds V3 SDK enables teams of all sizes to build scalable activity feeds. This SDK is designed to enable you to get an application up and running quickly and efficiently while supporting complex use cases.
In this tutorial, we will use Stream's Activity Feeds V3 SDK for Swift to:
- Set up a simple activity feed application and connect it to Stream's Activity Feeds V3 SDK.
- Create user and timeline feeds.
- Add activities, reactions and comments.
- Explore new content with "For you" feed.
Here is a quick visual overview of the application we're building:

Project Setup and Installation
To follow the tutorial make sure you have installed:
- Xcode - the tutorial is tested with Xcode 26
To follow the tutorial you need to clone or download the starter application that has some boilerplate code:
We'll start the tutorial from the initial commit, if you wish to see the finished source code, it's the latest commit in the stream-feeds-swift-tutorial repository.
1234git clone git@github.com:GetStream/stream-feeds-swift-tutorial.git cd stream-feeds-swift-tutorial git checkout initial-commit open ActivityFeedsTutorial.xcodeproj
Add Stream's Feeds v3 Swift SDK to your project:
- In Xcode, go to File → Add Package Dependencies
- Enter the repository URL:
https://github.com/GetStream/stream-feeds-swift.git - Click on the "Add Package" button which opens a sheet for configuring targets
- Make sure "Add to Target" has "ActivityFeedsTutorial" selected and click on the "Add Package" button
The tutorial application uses SwiftUI and async-await.
To make the tutorial as easy as possible, we generated credentials for you to pick up and use. These credentials consist of:
API_KEY- an API key that is used to identify your Stream application by our serversidandtoken- authorization information of the current username- optional, used as a display name of the current user
To start using credentials, replace the contents of the UserCredentials.swift file:
12345678910extension UserCredentials { static var current: UserCredentials { UserCredentials( apiKey: "REPLACE_WITH_API_KEY", id: "REPLACE_WITH_USER_ID", name: "REPLACE_WITH_USER_NAME", token: "REPLACE_WITH_TOKEN" ) } }
Security Note: In production applications, never expose your API secret or generate tokens on the client side. Tokens should always be generated on your backend server to ensure security. The credentials in this tutorial are for development purposes only.
You can build and run the application. For now, the application doesn't do much other than showing placeholder skeleton, but this will change as we complete the tutorial step by step.
Connect to the Stream API
Let's connect to the Stream API.
The starter application you cloned has all the necessary files, but not connected to the Activity Feeds SDK. You can update their content from the code snippets in the tutorial. No need to create any additional files.
To achieve this, we're using the FeedsClient to initialize the client and make it available throughout the app through SwiftUI environment. Let's update ActivityFeedsTutorialApp.swift with the client setup and RootView.swift to use the SwiftUI environment and connecting to the Stream API using passed in credentials.
1234567891011121314151617181920212223242526272829303132import StreamFeeds import SwiftUI @main struct ActivityFeedsTutorialApp: App { var body: some Scene { WindowGroup { RootView() .environment(\.feedsClient, FeedsClient.current) } } } extension FeedsClient { @MainActor static var current: FeedsClient = { LogConfig.level = .info let credentials = UserCredentials.current return FeedsClient( apiKey: APIKey(credentials.apiKey), user: User( id: credentials.id, name: credentials.name, imageURL: credentials.avatarURL ), token: UserToken(rawValue: credentials.token) ) }() } extension EnvironmentValues { @Entry var feedsClient: FeedsClient = FeedsClient.current }
123456789101112131415161718192021222324252627282930313233import StreamFeeds import SwiftUI struct RootView: View { @Environment(\.feedsClient) var feedsClient @State private var isConnected = false var body: some View { VStack { if isConnected { TabView { Tab("Home", systemImage: "house") { HomeView() } Tab("Explore", systemImage: "magnifyingglass") { ExploreView() } } } else { ProgressView("Connecting to the Stream API") } } .task(id: feedsClient.user.id) { do { try await feedsClient.connect() log.info("✅ User \(feedsClient.user.id) connected successfully") isConnected = true } catch { log.error("Failed to connect", error: error) } } } }
Launching the app will create a FeedsClient and connect to the Stream API. Xcode console shows a line that the user was connected successfully.
For simplicity, the tutorial doesn't handle errors. In a real application, you should always make sure to handle errors from API requests and display appropriate UI.
Creating Feeds
In this step we're creating a few feeds using built-in feed groups. Before we dive into the code, let's understand the core concepts:
- User Feed: A feed that contains all activities (posts) created by a specific user. Each user has their own user feed (e.g.,
user:alice). - Timeline Feed: A feed that contains activities from all the feeds that you follow. When you follow someone's user feed, their activities automatically appear in your timeline feed (this concept is called fan-out).
- Follow Relationship: When you follow a user's feed, your timeline feed subscribes to their user feed. This means new activities from followed users automatically appear in your timeline.
Let's see what the concept looks like in code (no need to add this to your app yet):
123456789101112let connectedUserId = client.user.id // Using user id for the feed id, but you can use any id you want to let ownFeed = client.feed( group: "user", id: connectedUserId ) try await ownFeed.getOrCreate() let ownTimeline = client.feed( group: "timeline", id: connectedUserId ) try await ownTimeline.getOrCreate()
To ensure our own posts are part of our timeline, we need to set up the follow relationship:
123456// You typically create these relationships on your server-side, we do this here for simplicity let alreadyFollows = ownFeed.state.feedData?.ownFollows? .contains(where: { $0.sourceFeed.feed == timeline.feed }) ?? false if !alreadyFollows { try await ownTimeline.follow(ownFeed.feed) }
Activity List
Activity Feeds SDKs don't have UI components (yet).
Now that we created feeds, we can create UI components to display the activities. To achieve this we're creating the following components:
ActivityViewcomponent to display individual activity informationActivityListViewcomponent to display activities, and paginate- We display the user's
timelinefeed on theHomeViewpage
The
ActivityViewcomponent contains basic activity information liketext,user,commentCount, andreactionCount.
123456789101112131415161718192021222324252627282930313233import StreamFeeds import SwiftUI struct ActivityView: View { let activityData: ActivityData var body: some View { VStack(alignment: .leading) { HStack { AvatarView(url: activityData.user.imageURL) VStack(alignment: .leading, spacing: 8) { HStack { Text(activityData.user.name ?? activityData.user.id) .font(.subheadline) .foregroundStyle(.primary) Text(activityData.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } Text(activityData.text ?? "") } Spacer() } HStack(spacing: 16) { Button("\(activityData.commentCount)", systemImage: "bubble") {} Button("\(activityData.reactionCount)", systemImage: "heart") {} } .foregroundStyle(.secondary) .padding(4) } .padding(.horizontal, 8) } }
1234567891011121314151617181920212223242526272829303132333435363738import StreamFeeds import SwiftUI struct ActivityListView: View { let feed: Feed @ObservedObject var state: FeedState init(feed: Feed) { self.feed = feed _state = ObservedObject(wrappedValue: feed.state) } var body: some View { if !state.activities.isEmpty { ScrollView { LazyVStack { ForEach(state.activities) { activityData in ActivityView(activityData: activityData) } if state.canLoadMoreActivities { Button("Load More") { Task { do { try await feed.queryMoreActivities() } catch { log.error("Failed to load more activities", error: error) } } } .buttonStyle(.borderedProminent) } } } } else { ContentUnavailableView("There are no activities for this feed", systemImage: "newspaper") } } }
12345678910111213141516171819202122232425262728293031323334353637383940import StreamFeeds import SwiftUI struct HomeView: View { @Environment(\.feedsClient) var client let timeline: Feed var body: some View { NavigationStack { VStack { ActivityComposerView() Divider() ActivityListView(feed: timeline) } .navigationTitle("Stream Activity Feeds") .navigationBarTitleDisplayMode(.inline) .task(id: timeline.feed) { do { try await timeline.getOrCreate() // You typically create these relationships on your server-side, we do this here for simplicity let connectedUserId = client.user.id let ownFeed = client.feed( group: "user", id: connectedUserId ) try await ownFeed.getOrCreate() let alreadyFollows = ownFeed.state.feedData?.ownFollows? .contains(where: { $0.sourceFeed.feed == timeline.feed }) ?? false if !alreadyFollows { try await timeline.follow(ownFeed.feed) } } catch { log.error("Failed to fetch own feed data", error: error) } } } } }
1234567891011121314151617181920212223242526272829303132333435363738import StreamFeeds import SwiftUI struct RootView: View { @Environment(\.feedsClient) var client @State private var isConnected = false var body: some View { VStack { if isConnected { TabView { Tab("Home", systemImage: "house") { HomeView( timeline: client.feed( group: "timeline", id: client.user.id ) ) } Tab("Explore", systemImage: "magnifyingglass") { ExploreView() } } } else { ProgressView("Connecting to the Stream API") } } .task(id: client.user.id) { do { try await client.connect() log.info("✅ User \(client.user.id) connected successfully") isConnected = true } catch { log.error("Failed to connect", error: error) } } } }
The activity list is currently empty. We'll change that in the next step. Before doing that, let's recap the important parts from this step:
- We're using the
Feedobject to access feed state and activities - Components can observe the feed's state using
@ObservedObjectto get real-time updates - The
feed.state.activitiesproperty provides access to the activities in the feed
Creating a
Feedobject sets up watching by default to receive real-time updates.
Activity Composer
Let's create an ActivityComposerView component to be able to post, and add it to the HomeView page:
As mentioned previously: users post on their
userfeed, and it automatically appears in theirtimelinefeed via follow relationship.
123456789101112131415161718192021222324252627282930313233343536373839404142import StreamFeeds import SwiftUI struct ActivityComposerView: View { @State private var text = "" let feed: Feed var body: some View { VStack(alignment: .leading) { TextField("What is happening?", text: $text) HStack { Spacer() Button( action: {}, label: { Image(systemName: "photo") } ) .buttonStyle(.bordered) Button( action: { Task { do { try await feed.addActivity( request: .init( text: text.trimmingCharacters(in: .whitespacesAndNewlines), type: "post" ) ) text = "" } catch { log.error("Failed to add activity", error: error) } } }, label: { Image(systemName: "paperplane") } ) .buttonStyle(.borderedProminent) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding() } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445import StreamFeeds import SwiftUI struct HomeView: View { @Environment(\.feedsClient) var client let timeline: Feed var body: some View { NavigationStack { VStack { ActivityComposerView( feed: client.feed( group: "user", id: client.user.id ) ) Divider() ActivityListView(feed: timeline) } .navigationTitle("Stream Activity Feeds") .navigationBarTitleDisplayMode(.inline) .task(id: timeline.feed) { do { try await timeline.getOrCreate() // You typically create these relationships on your server-side, we do this here for simplicity let connectedUserId = client.user.id let ownFeed = client.feed( group: "user", id: connectedUserId ) try await ownFeed.getOrCreate() let alreadyFollows = ownFeed.state.feedData?.ownFollows? .contains(where: { $0.sourceFeed.feed == timeline.feed }) ?? false if !alreadyFollows { try await timeline.follow(ownFeed.feed) } } catch { log.error("Failed to fetch own feed data", error: error) } } } } }
Go ahead and post something! It'll automatically appear on your timeline.
Explore Page
The "Explore" page uses the foryou feed to explore new content by showing popular activities.
The layout is similar to the HomeView, we're reusing the ActivityListView component, but there is no composer.
1234567891011121314151617181920import StreamFeeds import SwiftUI struct ExploreView: View { let feed: Feed var body: some View { NavigationStack { ActivityListView(feed: feed) .task(id: feed.feed) { do { try await feed.getOrCreate() } catch { log.error("Failed to load explore page", error: error) } } .navigationTitle("For You") } } }
12345678910111213141516171819202122232425262728293031323334353637383940414243import StreamFeeds import SwiftUI struct RootView: View { @Environment(\.feedsClient) var client @State private var isConnected = false var body: some View { VStack { if isConnected { TabView { Tab("Home", systemImage: "house") { HomeView( timeline: client.feed( group: "timeline", id: client.user.id ) ) } Tab("Explore", systemImage: "magnifyingglass") { ExploreView( feed: client.feed( group: "foryou", id: client.user.id ) ) } } } else { ProgressView("Connecting to the Stream API") } } .task(id: client.user.id) { do { try await client.connect() log.info("✅ User \(client.user.id) connected successfully") isConnected = true } catch { log.error("Failed to connect", error: error) } } } }
Note that
foryoufeed uses the "popular" activity selector, which doesn't support real-time updates. The documentation details how real-time updates work.
In the next step, we're adding a follow button, so we can follow users from the foryou page.
Follow and Unfollow
To implement following and unfollowing feeds we're:
- Extending the
ToggleFollowButtoncomponent to handle follow actions - Extending the
ActivityViewcomponent with the follow/unfollow button
12345678910111213141516171819202122232425262728293031323334353637import StreamFeeds import SwiftUI struct ToggleFollowButton: View { @Environment(\.feedsClient) var client let feedData: FeedData @State private var isFollowing: Bool init(feedData: FeedData) { self.feedData = feedData self.isFollowing = feedData.ownFollows?.count ?? 0 > 0 } var body: some View { Button(isFollowing ? "Unfollow" : "Follow") { Task { do { let ownTimeline = client.feed(group: "timeline", id: client.user.id) if isFollowing { try await ownTimeline.unfollow(feedData.feed) } else { try await ownTimeline.follow(feedData.feed) } isFollowing.toggle() } catch { log.error("Failed to toggle following", error: error) } } } .font(.callout) .padding(.horizontal, 8) .padding(.vertical, 4) .foregroundStyle(isFollowing ? Color.red : Color.green) .background(isFollowing ? Color.red.brightness(0.6) : Color.green.brightness(0.6)) .clipShape(RoundedRectangle(cornerRadius: 4)) } }
12345678910111213141516171819202122232425262728293031323334353637import StreamFeeds import SwiftUI struct ActivityView: View { @Environment(\.feedsClient) var client let activityData: ActivityData var body: some View { VStack(alignment: .leading) { HStack { AvatarView(url: activityData.user.imageURL) VStack(alignment: .leading, spacing: 8) { HStack { Text(activityData.user.name ?? activityData.user.id) .font(.subheadline) .foregroundStyle(.primary) Text(activityData.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } Text(activityData.text ?? "") } Spacer() } HStack(spacing: 16) { Button("\(activityData.commentCount)", systemImage: "bubble") {} Button("\(activityData.reactionCount)", systemImage: "heart") {} if let currentFeed = activityData.currentFeed, currentFeed.feed != FeedId(group: "user", id: client.user.id) { ToggleFollowButton(feedData: currentFeed) } } .foregroundStyle(.secondary) .padding(4) } .padding(.horizontal, 8) } }
Let's walk through the steps:
feed.follow()andfeed.unfollow()lets us follow and unfollow feeds.- In
ActivityViewcomponent we're usingactivityData.currentFeed.ownFollowsto know if the user's timeline feed follows the feed or notactivityData.currentFeedhas information about the feed the activity was posted to. It's useful if you're building Reddit-style applications where there is no 1:1 mapping between feeds and users. It lets you display name/image of the feed the activity belongs to.
- Stream API also supports follow requests where approval from feed owner is required to follow
Now that the follow button is working, you can start following another user using the "Explore" page.
Reactions
To make our application more interactive, we'll add reactions for activities.
To achieve this, we need to implement the reaction button in the ActivityView component and pass in the feed id from the ActivityListView.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import StreamFeeds import SwiftUI struct ActivityView: View { @Environment(\.feedsClient) var client let activityData: ActivityData let feedId: FeedId var body: some View { VStack(alignment: .leading) { HStack { AvatarView(url: activityData.user.imageURL) VStack(alignment: .leading, spacing: 8) { HStack { Text(activityData.user.name ?? activityData.user.id) .font(.subheadline) .foregroundStyle(.primary) Text(activityData.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } Text(activityData.text ?? "") } Spacer() } HStack(spacing: 16) { Button("\(activityData.commentCount)", systemImage: "bubble") {} Button( "\(activityData.reactionGroups["heart"]?.count ?? 0)", systemImage: activityData.ownReactions.isEmpty ? "heart" : "heart.fill" ) { Task { do { let activity = client.activity(for: activityData.id, in: feedId) if activityData.ownReactions.isEmpty { try await activity.addReaction(request: .init(type: "heart")) } else { try await activity.deleteReaction(type: "heart") } } catch { log.error("Failed to toggle reaction", error: error) } } } .foregroundStyle(activityData.ownReactions.isEmpty ? .secondary : Color.red) if let currentFeed = activityData.currentFeed, currentFeed.feed != FeedId(group: "user", id: client.user.id) { ToggleFollowButton(feedData: currentFeed) } } .foregroundStyle(.secondary) .padding(4) } .padding(.horizontal, 8) } }
1234567891011121314151617181920212223242526272829303132333435363738import StreamFeeds import SwiftUI struct ActivityListView: View { let feed: Feed @ObservedObject var state: FeedState init(feed: Feed) { self.feed = feed _state = ObservedObject(wrappedValue: feed.state) } var body: some View { if !state.activities.isEmpty { ScrollView { LazyVStack { ForEach(state.activities) { activityData in ActivityView(activityData: activityData, feedId: feed.feed) } if state.canLoadMoreActivities { Button("Load More") { Task { do { try await feed.queryMoreActivities() } catch { log.error("Failed to load more activities", error: error) } } } .buttonStyle(.borderedProminent) } } } } else { ContentUnavailableView("There are no activities for this feed", systemImage: "newspaper") } } }
You can use the demo app to follow your tutorial user, and to react to their activities.
Let's recap what we did in this step:
feed.addReaction()andfeed.deleteReaction()toggles reactionstypecan be any string you'd like- Since the Swift SDK provides reactive state management through
@ObservedObject, the UI is automatically updated anytime anything on the activity changes
- We use
activity.ownReactionsandactivity.reactionGroupsto get real-time reaction data for activity - Some advanced features not shown in tutorial:
- A single user can add multiple reactions to an activity
- Comments can have reactions too
- Check out the activity reactions and comment reactions pages in the documentation for more information
Comments
Comments are another good way to add interactivity to your app. To add this feature, we need to do the following:
- Implementing
CommentListViewto list comments and post comments - Implementing
CommentViewto show individual comment information - Extending
ActivityViewcomponent to show comments
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384import StreamFeeds import SwiftUI struct CommentListView: View { @Environment(\.feedsClient) var client let activity: Activity let commentList: ActivityCommentList @ObservedObject var state: ActivityCommentListState @State private var commentText = "" init(activity: Activity, commentList: ActivityCommentList) { self.activity = activity self.commentList = commentList _state = ObservedObject(wrappedValue: commentList.state) } var body: some View { NavigationStack { VStack { if !state.comments.isEmpty { ScrollView { LazyVStack { ForEach(state.comments) { comment in CommentView( author: comment.user.name ?? comment.user.id, createdAt: comment.createdAt, text: comment.text ?? "" ) } if state.canLoadMore { Button("Load More") { Task { do { try await commentList.queryMoreComments() } catch { log.error("Failed to load more comments", error: error) } } } .buttonStyle(.borderedProminent) } } } } else { ContentUnavailableView("There are no comments for this activity", systemImage: "bubble") } Divider() HStack { TextField("Write a comment…", text: $commentText) Button( action: { Task { do { try await activity.addComment( request: .init( comment: commentText.trimmingCharacters(in: .whitespacesAndNewlines) ) ) commentText = "" } catch { log.error("Failed to add a comment", error: error) } } }, label: { Image(systemName: "paperplane") } ) .buttonStyle(.borderedProminent) .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() } .navigationTitle("Comments") .task(id: commentList.query.objectId) { do { try await commentList.get() } catch { log.error("Failed to fetch comments", error: error) } } } } }
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374import StreamFeeds import SwiftUI struct ActivityView: View { @Environment(\.feedsClient) var client let activityData: ActivityData let feedId: FeedId @State private var showsComments = false var body: some View { VStack(alignment: .leading) { HStack { AvatarView(url: activityData.user.imageURL) VStack(alignment: .leading, spacing: 8) { HStack { Text(activityData.user.name ?? activityData.user.id) .font(.subheadline) .foregroundStyle(.primary) Text(activityData.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } Text(activityData.text ?? "") } Spacer() } HStack(spacing: 16) { Button("\(activityData.commentCount)", systemImage: "bubble") { showsComments = true } Button( "\(activityData.reactionGroups["heart"]?.count ?? 0)", systemImage: activityData.ownReactions.isEmpty ? "heart" : "heart.fill" ) { Task { do { let activity = client.activity(for: activityData.id, in: feedId) if activityData.ownReactions.isEmpty { try await activity.addReaction(request: .init(type: "heart")) } else { try await activity.deleteReaction(type: "heart") } } catch { log.error("Failed to toggle reaction", error: error) } } } .foregroundStyle(activityData.ownReactions.isEmpty ? .secondary : Color.red) if let currentFeed = activityData.currentFeed, currentFeed.feed != FeedId(group: "user", id: client.user.id) { ToggleFollowButton(feedData: currentFeed) } } .foregroundStyle(.secondary) .padding(4) } .padding(.horizontal, 8) .sheet(isPresented: $showsComments) { CommentListView( activity: client.activity( for: activityData.id, in: feedId ), commentList: client.activityCommentList( for: .init( objectId: activityData.id, objectType: "activity" ) ) ) .presentationDetents([.fraction(0.75)]) .presentationDragIndicator(.visible) } } }
Let's recap what happened in this step:
activity.addComment()lets us create a commentclient.activityCommentList()lets you read and paginate comments- Stream API provides multiple ways to sort comments
activity.commentCountstores how many comments the activity has
Comments can be threaded/nested too (not shown in the tutorial).
Posting Images
Stream API allows attaching files to activities and comments. Let's extend our app with attaching images to activities. To achieve this we need to:
- Extend the
ActivityComposerViewto pick and send attachments with the activity - Extend the
ActivityViewcomponent to display attachments using theThumbnailImagecomponent part of the tutorial app
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import PhotosUI import StreamFeeds import SwiftUI struct ActivityComposerView: View { @State private var text = "" let feed: Feed @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var photoURLs = [URL]() var body: some View { VStack(alignment: .leading) { TextField("What is happening?", text: $text) if !photoURLs.isEmpty { HStack { ForEach(photoURLs, id: \.absoluteString) { url in ThumbnailImage(url: url, size: CGSize(width: 50, height: 50)) } } } HStack { Spacer() Button( action: {}, label: { Image(systemName: "photo") } ) .buttonStyle(.bordered) .overlay { PhotosPicker( selection: $selectedPhotos, matching: .images ) { Color.clear } } Button( action: { Task { do { let attachments = try photoURLs .compactMap { try AnyAttachmentPayload(localFileURL: $0, attachmentType: .image) } try await feed.addActivity( request: .init( attachmentUploads: attachments, text: text.trimmingCharacters(in: .whitespacesAndNewlines), type: "post" ) ) photoURLs = [] text = "" } catch { log.error("Failed to add activity", error: error) } } }, label: { Image(systemName: "paperplane") } ) .buttonStyle(.borderedProminent) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && photoURLs.isEmpty) } } .padding() .task(id: selectedPhotos, { do { photoURLs = try await withThrowingTaskGroup { group in for (index, item) in selectedPhotos.enumerated() { group.addTask { let localURL = URL.temporaryDirectory.appending(path: "\(index)-\(item.itemIdentifier ?? UUID().uuidString)") guard let photoData = try await item.loadTransferable(type: Data.self) else { throw ClientError.InvalidURL() } try photoData.write(to: localURL) return localURL } } return try await group.reduce(into: []) { $0.append($1) } } } catch { log.error("Failed to prepare photos", error: error) } }) } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687import StreamFeeds import SwiftUI struct ActivityView: View { @Environment(\.feedsClient) var client let activityData: ActivityData let feedId: FeedId @State private var showsComments = false var imageURLs: [URL] { activityData.attachments.compactMap(\.imageUrl).compactMap { URL(string: $0) } } var body: some View { VStack(alignment: .leading) { HStack(alignment: .top) { AvatarView(url: activityData.user.imageURL) VStack(alignment: .leading, spacing: 8) { HStack { Text(activityData.user.name ?? activityData.user.id) .font(.subheadline) .foregroundStyle(.primary) Text(activityData.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } if let text = activityData.text, !text.isEmpty { Text(text) } ForEach(imageURLs, id: \.absoluteString) { url in ThumbnailImage( url: url, size: CGSize(width: 200, height: 150) ) } } Spacer() } HStack(spacing: 16) { Button("\(activityData.commentCount)", systemImage: "bubble") { showsComments = true } Button( "\(activityData.reactionGroups["heart"]?.count ?? 0)", systemImage: activityData.ownReactions.isEmpty ? "heart" : "heart.fill" ) { Task { do { let activity = client.activity(for: activityData.id, in: feedId) if activityData.ownReactions.isEmpty { try await activity.addReaction(request: .init(type: "heart")) } else { try await activity.deleteReaction(type: "heart") } } catch { log.error("Failed to toggle reaction", error: error) } } } .foregroundStyle(activityData.ownReactions.isEmpty ? .secondary : Color.red) if let currentFeed = activityData.currentFeed, currentFeed.feed != FeedId(group: "user", id: client.user.id) { ToggleFollowButton(feedData: currentFeed) } } .foregroundStyle(.secondary) .padding(4) } .padding(.horizontal, 8) .sheet(isPresented: $showsComments) { CommentListView( activity: client.activity( for: activityData.id, in: feedId ), commentList: client.activityCommentList( for: .init( objectId: activityData.id, objectType: "activity" ) ) ) .presentationDetents([.fraction(0.75)]) .presentationDragIndicator(.visible) } } }
Go ahead and post an image! Or send a URL, as Stream API can automatically attach URL metadata as an attachment.
Final Thoughts
In this tutorial, we built a fully-functioning activity feed application with Stream's Activity Feed V3 SDK. We showed how easy it is to:
- Set up a simple activity feed application and connect it to Stream's Activity Feed V3 SDK.
- Create user and timeline feeds.
- Add activities, reactions and comments.
- Explore new content with "For you" feed.
Even though this was a long tutorial, Activity Feed V3 has even more features:
- Activity selectors and ranking for customizing what content to show for users
- Activity processors for extracting topics from activity content
- Notification feeds (with aggregation)
- Story feed (activity expiration)
- Custom feed groups
- Feed and activity visibility including premium activities with feed memberships
- Moderation and fine-grained permission system
- Polls
- For more Swift examples, check out stream-feeds-swift repository
