Improve channel list rendering performance#1529
Conversation
Track the perf/channel-list-performance-improvements branch of stream-chat-swift so the channel list rendering changes build against the matching core-SDK improvements until they are released.
Apply the difference between the current and new channel arrays when the channel list controller reports changes, so unchanged channels keep their identity and position and the list only reconciles rows that actually changed.
Build a channel id to index map once per body evaluation and use it to resolve a row's index on appearance, instead of scanning the whole channel array with firstIndex(where:) for every row.
Attach the divider as a bottom overlay on each row instead of adding it as a separate sibling node, halving the number of view-list entries the list has to place on every layout pass.
Drop the hardcoded 30 limit when paginating the channel list so it follows the page size configured on the channel list query instead.
Add a ChannelsList component that renders the channel list with a native SwiftUI List, used by default on iOS 15 and later. List truly virtualizes its rows, keeping layout cost flat as the channel count grows, while the LazyVStack path remains the fallback on iOS 14.
Load the next page of channels only once one of the last 8 rows appears instead of the last 15, so pagination fires less eagerly while scrolling.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughChannelList now uses an iOS 15+ native List path with shared row rendering and indexed appearance callbacks. Pagination starts earlier, channel updates apply diffs, and tests, changelog, and project dependency configuration are updated. ChangesChannel List Performance Improvements
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ChannelList
participant ChannelListItemsContainer
participant ChannelListItemRow
participant ChannelListViewModel
ChannelList->>ChannelListItemsContainer: render native or lazy channel items
ChannelListItemsContainer->>ChannelListItemRow: provide channel and divider state
ChannelListItemRow->>ChannelListItemsContainer: report appearance with indexed lookup
ChannelListViewModel->>ChannelListViewModel: apply channel differences
ChannelListViewModel->>ChannelListViewModel: trigger pagination near list end
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift (1)
242-355: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract shared row logic to eliminate duplication between
ChannelsLazyVStackandChannelsList.
makeChannelIndexLookup(),name(for:), and the row rendering pipeline (factory.makeChannelListItem→.background→.overlay→.onAppear) are nearly identical across both structs. Any future change to row rendering, divider logic, or index lookup must be applied in two places, risking drift.Consider extracting a shared row-builder helper (e.g., a
@ViewBuilderfunction or a reusableViewModifier) that bothLazyVStackandListForEachbodies call, parameterized by the divider visibility and lookup map.♻️ Suggested shared row builder
+ `@ViewBuilder` + fileprivate func channelRow( + for channel: ChatChannel, + channelIndexLookup: [String: Int], + showsDivider: Bool + ) -> some View { + factory.makeChannelListItem( + options: ChannelListItemOptions( + channel: channel, + channelName: name(for: channel), + disabled: swipedChannelId == channel.id, + selectedChannel: $selectedChannel, + swipedChannelId: $swipedChannelId, + channelDestination: channelDestination, + onItemTap: onItemTap, + trailingSwipeRightButtonTapped: trailingSwipeRightButtonTapped, + trailingSwipeLeftButtonTapped: trailingSwipeLeftButtonTapped, + leadingSwipeButtonTapped: leadingSwipeButtonTapped + ) + ) + .background(factory.makeChannelListItemBackground( + options: ChannelListItemBackgroundOptions( + channel: channel, + isSelected: selectedChannel?.channel.id == channel.id + ) + )) + .overlay( + Group { + if showsDivider { + factory.makeChannelListDividerItem(options: ChannelListDividerItemOptions()) + } + }, + alignment: .bottom + ) + .onAppear { + if let index = channelIndexLookup[channel.id] { + onItemAppear(index) + } + } + }Both
ChannelsLazyVStackandChannelsListcould then callchannelRow(for:channelIndexLookup:showsDivider:)inside their respectiveForEachloops, withChannelsListapplying the additional.listRowInsets/.listRowSeparator/.listRowBackgroundmodifiers after the shared builder.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift` around lines 242 - 355, `ChannelsList` duplicates the same row-building logic already present in `ChannelsLazyVStack`, including `makeChannelIndexLookup()`, `name(for:)`, and the `factory.makeChannelListItem` chain with background, overlay, and onAppear handling. Extract that shared row pipeline into a reusable helper such as a `@ViewBuilder` channel row function or a shared `ViewModifier`, parameterized by the lookup map and divider visibility, so both structs call the same implementation and `ChannelsList` only adds its `List`-specific row modifiers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift`:
- Around line 286-334: The ChatChannelList view is only clearing row
backgrounds, so the List still shows its default scroll content background on
iOS 16+. Update the List builder in ChatChannelList to apply
.scrollContentBackground(.hidden) alongside the existing .listStyle(.plain) and
row background settings, keeping the change near the ForEach(channels) list
rendering and footer view.
---
Nitpick comments:
In `@Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift`:
- Around line 242-355: `ChannelsList` duplicates the same row-building logic
already present in `ChannelsLazyVStack`, including `makeChannelIndexLookup()`,
`name(for:)`, and the `factory.makeChannelListItem` chain with background,
overlay, and onAppear handling. Extract that shared row pipeline into a reusable
helper such as a `@ViewBuilder` channel row function or a shared `ViewModifier`,
parameterized by the lookup map and divider visibility, so both structs call the
same implementation and `ChannelsList` only adds its `List`-specific row
modifiers.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4a11e2f3-f9e1-4da1-b7cb-048cae25ccd8
📒 Files selected for processing (3)
Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swiftSources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swiftStreamChatSwiftUI.xcodeproj/project.pbxproj
Re-record the ChatChannelListView reference snapshots affected by the switch to a native List-backed channel list. The visuals are unchanged; only sub-pixel layout differences required new references.
Extract shared row, footer, and index lookup logic used by both the LazyVStack and native List implementations, and add unit tests for the lookup helper and pagination prefetch threshold.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift (1)
68-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePagination boundary tests are correct.
The threshold in
checkForChannels(index:)iscount - 8 = 12for 20 channels, so index 11 correctly does not trigger and index 12 does. The deterministic channel IDs are an improvement overmockDMChannel()for this scenario.The 20-channel setup is duplicated verbatim in both new tests. Consider extracting a small helper to reduce duplication.
♻️ Optional: extract shared channel setup
+ private func makeTwentyChannels() -> [ChatChannel] { + (0..<20).map { + ChatChannel.mock(cid: ChannelId(type: .messaging, id: "\($0)")) + } + } + func test_channelListVM_onChannelAppear_loadNextChannelsNotCalled() { // Given - var channels = [ChatChannel]() - for index in 0..<20 { - channels.append(ChatChannel.mock(cid: ChannelId(type: .messaging, id: "\(index)"))) - } + let channels = makeTwentyChannels() let channelListController = makeChannelListController(channels: channels)Apply the same change to
test_channelListVM_onChannelAppear_loadNextChannelsCalledNearEnd.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift` around lines 68 - 104, The new pagination boundary tests are correct, but the 20-channel setup is duplicated across both ChatChannelListViewModel_Tests cases. Extract the repeated channel creation into a small shared helper in ChatChannelListViewModel_Tests, then update test_channelListVM_onChannelAppear_loadNextChannelsNotCalled and test_channelListVM_onChannelAppear_loadNextChannelsCalledNearEnd to use it so the setup stays consistent and easier to maintain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift`:
- Around line 68-104: The new pagination boundary tests are correct, but the
20-channel setup is duplicated across both ChatChannelListViewModel_Tests cases.
Extract the repeated channel creation into a small shared helper in
ChatChannelListViewModel_Tests, then update
test_channelListVM_onChannelAppear_loadNextChannelsNotCalled and
test_channelListVM_onChannelAppear_loadNextChannelsCalledNearEnd to use it so
the setup stays consistent and easier to maintain.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 65648004-c542-4e97-9163-e79f46125244
⛔ Files ignored due to path filters (6)
StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_channelListView_channelAvatarUpdated.1.pngis excluded by!**/*.pngStreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_channelListView_themedNavigationBar.1.pngis excluded by!**/*.pngStreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_chatChannelListViewSansNavigation_snapshot.1.pngis excluded by!**/*.pngStreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_chatChannelListView_showChannelListDividerOnLastItem_snapshot.disabled.pngis excluded by!**/*.pngStreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_chatChannelListView_showChannelListDividerOnLastItem_snapshot.enabled.pngis excluded by!**/*.pngStreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_chatChannelListView_snapshot.1.pngis excluded by!**/*.png
📒 Files selected for processing (4)
Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swiftStreamChatSwiftUI.xcodeproj/project.pbxprojStreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swiftStreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelList_Tests.swift
🚧 Files skipped from review as they are similar to previous changes (2)
- StreamChatSwiftUI.xcodeproj/project.pbxproj
- Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift
Route ChannelList through a single items container and keep ChannelsLazyVStack as a thin compatibility wrapper.
Cover search reset, channel removal, the compatibility wrapper, and additional index lookup scenarios to satisfy the new-code coverage gate.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelList_Tests.swift`:
- Around line 54-57: The assertion in
test_channelsLazyVStack_rendersProvidedChannels is checking the local channels
array rather than anything produced by evaluating view.body, so it does not
verify rendering. Update the test to assert on actual rendered output from
ChatChannelListContainer or the LazyVStack path, or replace the current count
check with a crash-free body evaluation expectation. Use the existing test
method name and the view.body evaluation in ChatChannelList_Tests to locate the
change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 60da9901-b4c3-4f2e-a452-f98df9a33932
📒 Files selected for processing (3)
CHANGELOG.mdStreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swiftStreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelList_Tests.swift
✅ Files skipped from review due to trivial changes (1)
- CHANGELOG.md
The actual measured reduction is 2.5 MB, not 2 MB.
The native List introduced conflicts with custom swipe-gesture implementations in customized ViewFactorys. Go back to always rendering the channel list with LazyVStack/ScrollView, keeping the other performance fixes (diffing, O(1) index lookup, divider as overlay) in place.
This reverts commit ea3a97c.
Public Interface🚀 No changes affecting the public interface. |
Re-record ChatChannelListView reference snapshots on iPhone 17 Pro (iOS 26.2) after reverting the native List rendering path.
martinmitrevski
left a comment
There was a problem hiding this comment.
I'm not sure if these changes have a big impact on performance tbh. I think the LLC ones contribute more, can you share some more data?
This reverts commit e4a651c.
Drop diffed updates, native List, O(1) index lookup, test additions, and snapshot changes. Keep only the divider overlay refactor and using the channel list query page size when loading more channels.
Document divider overlay improvement and 2.5 MB SDK size reduction.
martinmitrevski
left a comment
There was a problem hiding this comment.
Nice performance boost!
Track perf/channel-list-performance-improvements in Package.swift and the Xcode project so the SwiftUI PR builds against the LLC perf changes.
SDK Size
|
StreamChatSwiftUI XCSize
Show 98 more objects
|
Restore CI-recorded references for channel list snapshots after keeping the divider-as-overlay rendering change.
|



🔗 Issue Links
🎯 Goal
Reduce main-thread hangs while scrolling and paginating the channel list.
📝 Summary
Diff channel updates instead of replacing the whole array, soForEachonly reconciles rows that actually changed.🛠 Implementation
Channel updates are applied with
Array.difference(from:)so unchanged rows keep their identity, and row index resolution is O(1). Pagination fires less eagerly (within the last 8 rows, default page size). Row visuals are unchanged.🎨 Showcase
With all these fixes, including LLC changes, we can drop from around 70 hangs when scrolling fast through new pages to just 14. In order to completely drop the hangs to 0, the only way is actually to bypass the database. I've done a prototype, and only without the CoreData processing can I get a complete 0 hangs.
Before
After
🧪 Manual Testing Notes
Scroll a large channel list and paginate; scrolling should stay smooth without main-thread hangs. Row visuals, dividers, selection and swipe actions should be unchanged.
☑️ Contributor Checklist
docs-contentrepo