Build low-latency Vision AI applications using our new open-source Vision AI SDK. ⭐️ on GitHub ->

iOS Activity Feed Tutorial

Create native iOS activity feeds powered by the Stream Feeds iOS SDK.
Learn how to integrate Stream Feeds into your iOS app, manage activities, and deliver fast, real-time feed updates with Swift.

example of swift ui feeds sdk

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.

bash
1
2
3
4
git 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:

  1. In Xcode, go to File → Add Package Dependencies
  2. Enter the repository URL: https://github.com/GetStream/stream-feeds-swift.git
  3. Click on the "Add Package" button which opens a sheet for configuring targets
  4. 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 servers
  • id and token - authorization information of the current user
  • name - optional, used as a display name of the current user

To start using credentials, replace the contents of the UserCredentials.swift file:

UserCredentials.swift (swift)
1
2
3
4
5
6
7
8
9
10
extension 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.

ActivityFeedsTutorialApp.swift
RootView.swift
ActivityFeedsTutorialApp.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 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 }

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):

swift
1
2
3
4
5
6
7
8
9
10
11
12
let 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:

swift
1
2
3
4
5
6
// 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:

  • ActivityView component to display individual activity information
  • ActivityListView component to display activities, and paginate
  • We display the user's timeline feed on the HomeView page

The ActivityView component contains basic activity information like text, user, commentCount, and reactionCount.

ActivityView.swift
ActivityListView.swift
HomeView.swift
RootView.swift
ActivityView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import 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) } }

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 Feed object to access feed state and activities
  • Components can observe the feed's state using @ObservedObject to get real-time updates
  • The feed.state.activities property provides access to the activities in the feed

Creating a Feed object 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 user feed, and it automatically appears in their timeline feed via follow relationship.

ActivityComposerView.swift
HomeView.swift
ActivityComposerView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import 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() } }

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.

ExploreView.swift
RootView.swift
ExploreView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 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") } } }

Note that foryou feed 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 ToggleFollowButton component to handle follow actions
  • Extending the ActivityView component with the follow/unfollow button
ToggleFollowButton.swift
ActivityView.swift
ToggleFollowButton.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import 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)) } }

Let's walk through the steps:

  1. feed.follow() and feed.unfollow() lets us follow and unfollow feeds.
  2. In ActivityView component we're using activityData.currentFeed.ownFollows to know if the user's timeline feed follows the feed or not
    • activityData.currentFeed has 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.
  3. 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.

ActivityView.swift
ActivityListView.swift
ActivityView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import 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) } }

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:

  1. feed.addReaction() and feed.deleteReaction() toggles reactions
    • type can 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
  2. We use activity.ownReactions and activity.reactionGroups to get real-time reaction data for activity
  3. 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 CommentListView to list comments and post comments
  • Implementing CommentView to show individual comment information
  • Extending ActivityView component to show comments
CommentListView.swift
ActivityView.swift
CommentListView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import 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) } } } } }

Let's recap what happened in this step:

  1. activity.addComment() lets us create a comment
  2. client.activityCommentList() lets you read and paginate comments
  3. activity.commentCount stores 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 ActivityComposerView to pick and send attachments with the activity
  • Extend the ActivityView component to display attachments using the ThumbnailImage component part of the tutorial app
ActivityComposerView.swift
ActivityView.swift
ActivityComposerView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import 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) } }) } }

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:

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding

If you're interested in a custom plan or have any questions, please contact us.