Channel List Item

The channel list item is the view displayed for each channel inside ChatChannelListView. The SwiftUI SDK exposes three levels of customization for this channel item, from the broadest to the most granular. Pick the one that matches how much of the default UI you want to keep.

  1. Replace the channel item entirely via the ViewFactory.
  2. Subclass ChatChannelListItemViewModel to tweak presentation logic while keeping the default layout.
  3. Compose your own channel item from the public sub-views (title, message preview, mute icon, attachment icon, ...).

Replacing the Channel Item

To replace the channel item with your own view, implement makeChannelListItem in the ViewFactory protocol and return your custom view. The SDK ships two composable wrappers that you can stack around any channel item view to keep the standard behaviors:

  • ChatChannelNavigatableListItem adds navigation handling: it pushes the configured channel destination when selectedChannel matches the current channel.
  • ChatChannelSwipeableListItem adds the swipe-to-action gestures and trailing/leading action buttons.

Both wrappers accept any View via their channelListItem: parameter, so you can plug your own custom channel item view at the bottom of the stack:

public func makeChannelListItem(
    options: ChannelListItemOptions<ChannelDestination>
) -> some View {
    let customChannelItem = CustomChannelItemView(
        channel: options.channel,
        onTap: { options.onItemTap(options.channel) }
    )

    let navigatable = ChatChannelNavigatableListItem(
        channel: options.channel,
        channelListItem: customChannelItem,
        channelDestination: options.channelDestination,
        selectedChannel: options.selectedChannel
    )

    return ChatChannelSwipeableListItem(
        factory: self,
        channelListItem: navigatable,
        swipedChannelId: options.swipedChannelId,
        channel: options.channel,
        numberOfTrailingItems: options.channel.ownCapabilities.contains(.deleteChannel) ? 2 : 1,
        trailingRightButtonTapped: options.trailingSwipeRightButtonTapped,
        trailingLeftButtonTapped: options.trailingSwipeLeftButtonTapped,
        leadingSwipeButtonTapped: options.leadingSwipeButtonTapped
    )
}

Your custom channel item view is responsible for handling its own tap. Call options.onItemTap(options.channel) from its tap handler so the navigation wrapper above can react and push the destination. Each wrapper is optional: omit ChatChannelNavigatableListItem if your custom channel item drives navigation itself, or omit ChatChannelSwipeableListItem if you don't need swipe actions.

The ChannelListItemOptions provides the following properties:

  • channel: the channel being displayed in the list item.
  • channelName: the display name of the channel.
  • disabled: whether user interactions with the channel are disabled. Use this while the view is being swiped to prevent accidentally tapping into the channel.
  • selectedChannel: binding of the currently selected channel selection info (channel and optional message).
  • swipedChannelId: binding of the currently swiped channel id.
  • channelDestination: closure that creates the channel destination.
  • onItemTap: called when a channel list item is tapped.
  • trailingSwipeRightButtonTapped: called when the right button of the trailing swiped area is tapped.
  • trailingSwipeLeftButtonTapped: called when the left button of the trailing swiped area is tapped.
  • leadingSwipeButtonTapped: called when the button of the leading swiped area is tapped.

The last three callbacks have no effect if you return EmptyView for the leading and trailing swipe areas. By default, the leading area returns EmptyView. The Swipe Actions for the Channel List page covers how these areas can be customized.

To integrate your customization into the SDK, you will need to create a new CustomFactory (or any name you prefer), and provide your implementation of the method there. Afterwards, you will need to inject the newly created CustomFactory into our view hierarchy.

var body: some Scene {
    WindowGroup {
        ChatChannelListView(viewFactory: CustomFactory.shared)
    }
}

Customizing the View Model

ChatChannelListItemViewModel is an open class that owns the presentation logic for the default channel item. Properties that carry presentation logic are open, so you can subclass and override the individual ones you care about without forking the channel item layout.

Two of the most common properties to customize are the message preview text and the timestamp. The example below hides the message content behind a generic placeholder for unread channels and formats the timestamp with a custom date format:

final class CustomChannelListItemViewModel: ChatChannelListItemViewModel {
    /// Hide the message content in previews and show a placeholder instead.
    override var messagePreviewText: String {
        hasUnread ? "New message" : super.messagePreviewText
    }

    /// Use a custom time format for the timestamp.
    override var timestampText: String {
        guard let lastMessageAt = channel.lastMessageAt else { return "" }
        return Self.timeFormatter.string(from: lastMessageAt)
    }

    private static let timeFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm"
        return formatter
    }()
}

Each override still falls back to super for the cases it doesn't handle, so the rest of the default presentation logic stays intact.

To plug your view model into the channel list, use the viewModel: initializer on ChatChannelListItem from your ViewFactory:

public func makeChannelListItem(
    options: ChannelListItemOptions<ChannelDestination>
) -> some View {
    ChatChannelListItem(
        factory: self,
        viewModel: CustomChannelListItemViewModel(
            channel: options.channel,
            channelName: options.channelName
        ),
        isSelected: options.selectedChannel.wrappedValue?.channel.cid == options.channel.cid,
        disabled: options.disabled,
        onItemTap: options.onItemTap
    )
}

Composing from existing subviews

When you need a custom channel item layout but still want the SDK's styling for individual pieces, use the public sub-views directly. The default ChatChannelListItem is composed of the following building blocks, each of which you can use on its own to re-layout the channel item:

  • ChannelAvatar: the leading avatar (or stacked member avatars for group channels).
  • ChannelItemTitleView: the channel name plus the optional inline mute icon.
  • BadgeNotificationView: the trailing unread count badge.
  • MessageReadIndicatorView: the read/delivered/sending status checkmark next to the preview.
  • ChannelItemPreviewView: the second line that shows the latest activity (message, typing, draft, deleted, or failed to send).

Every sub-view takes primitive values (strings, booleans, images, counts) so it can be reused in any layout. You can drive them yourself or let ChatChannelListItemViewModel produce the right values for each channel.

Channel Item Preview

The preview is the second line of the channel item. It shows the most recent activity in the channel: the latest message (with author prefix, attachment glyph, or deleted placeholder), a pending draft, a typing indicator, or a "failed to send" status.

ChannelItemPreviewView is the view that displays the preview. The variant it shows is described by a ChannelItemPreview value, built via one of its static factory methods. Each method takes the content value for that variant:

ChannelItemPreviewView(preview: .failedToSend(.init()))
ChannelItemPreviewView(preview: .typing(.init(channel: channel)))
ChannelItemPreviewView(preview: .draft(.init(text: "Hi there")))
ChannelItemPreviewView(preview: .deleted(.init(isSentByCurrentUser: true)))
ChannelItemPreviewView(preview: .message(.init(text: "Last message")))

Each built-in variant has its own content type nested under ChannelItemPreview:

  • FailedToSendContent
  • TypingContent
  • DraftContent
  • DeletedContent
  • MessageContent

The actual content is stored on ChannelItemPreview.content, typed as any ChannelItemPreview.Content. To render your own view per variant, switch on it and match the concrete content type:

switch viewModel.preview.content {
case let draft as ChannelItemPreview.DraftContent:
    Text(draft.text).italic()
case is ChannelItemPreview.TypingContent:
    Text("typing...")
default:
    ChannelItemPreviewView(preview: viewModel.preview)
}

If you only need one specific variant, each variant is also exposed as its own View:

  • ChannelItemFailedToSendView
  • ChannelItemDraftPreviewView
  • ChannelItemDeletedPreviewView
  • ChannelItemMessagePreviewView
  • SubtitleTypingIndicatorView

Composing with the View Model

ChatChannelListItemViewModel already exposes everything the building blocks above need (avatar channel, title, unread state, read users, preview variant, ...). The view model is not observable and performs no extra work on its own, so you can build it inline and feed its values into your own layout.

The example below rearranges the default pieces into a compact two-line item using the view model to drive the data:

struct CompactChannelItemView: View {
    let viewModel: ChatChannelListItemViewModel
    let isSelected: Bool

    var body: some View {
        HStack(spacing: 12) {
            ChannelAvatar(channel: viewModel.channel, size: 48)

            VStack(alignment: .leading, spacing: 2) {
                HStack {
                    ChannelItemTitleView(
                        channelName: viewModel.channelName,
                        showInlineMutedIcon: viewModel.showInlineMutedIcon
                    )
                    Spacer()
                    Text(viewModel.timestampText)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                    if !isSelected && viewModel.hasUnread {
                        BadgeNotificationView(count: viewModel.unreadCount)
                    }
                }

                HStack(spacing: 4) {
                    if viewModel.showReadEvents {
                        MessageReadIndicatorView(
                            readUsers: viewModel.readUsers,
                            showDelivered: viewModel.showDelivered,
                            localState: viewModel.previewMessageLocalState
                        )
                    }
                    ChannelItemPreviewView(preview: viewModel.preview)
                }
            }
        }
    }
}

Combine this with the makeChannelListItem factory override from Replacing the Channel Item to plug your custom channel item view into the channel list, and with a CustomChannelListItemViewModel subclass to tweak the data it displays.