Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Development/Development.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
4BEAFA4C2A3CE48800478C59 /* AsyncMultiplexImage in Frameworks */ = {isa = PBXBuildFile; productRef = 4BEAFA4B2A3CE48800478C59 /* AsyncMultiplexImage */; };
4BEAFA4E2A3CE48800478C59 /* AsyncMultiplexImage-Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 4BEAFA4D2A3CE48800478C59 /* AsyncMultiplexImage-Nuke */; };
4BEBA5682D3EC5A200BDE020 /* BookRerender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */; };
B058628E2EACBD6300E87706 /* BookChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B058628D2EACBD6100E87706 /* BookChat.swift */; };
B05862902EACBD8900E87706 /* ChatUI in Frameworks */ = {isa = PBXBuildFile; productRef = B058628F2EACBD8900E87706 /* ChatUI */; };
B0699F292E8863750098A042 /* SelectableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F282E8863750098A042 /* SelectableForEach */; };
B0699F2B2E8863790098A042 /* StickyHeader in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F2A2E8863790098A042 /* StickyHeader */; };
B0699F2D2E88637D0098A042 /* RefreshControl in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F2C2E88637D0098A042 /* RefreshControl */; };
Expand Down Expand Up @@ -62,13 +64,15 @@
4BD04C182B2C15E100FE41D9 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
4BEAFA482A3CE3B100478C59 /* BookVariadicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVariadicView.swift; sourceTree = "<group>"; };
4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRerender.swift; sourceTree = "<group>"; };
B058628D2EACBD6100E87706 /* BookChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChat.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
4B26A6742A33239500B75FB4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B05862902EACBD8900E87706 /* ChatUI in Frameworks */,
4B08E0AD2CF5947100B05999 /* ScrollTracking in Frameworks */,
4BC34FAF2CDB1B9200D22811 /* CollectionView in Frameworks */,
4BEAFA4E2A3CE48800478C59 /* AsyncMultiplexImage-Nuke in Frameworks */,
Expand Down Expand Up @@ -105,6 +109,7 @@
4B26A6792A33239500B75FB4 /* Development */ = {
isa = PBXGroup;
children = (
B058628D2EACBD6100E87706 /* BookChat.swift */,
4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */,
4B9223612D3CE6B5007E20CB /* GlobalCounter.swift */,
4B08E0AA2CF5805200B05999 /* BookScrollView.swift */,
Expand Down Expand Up @@ -165,6 +170,7 @@
B0699F282E8863750098A042 /* SelectableForEach */,
B0699F2A2E8863790098A042 /* StickyHeader */,
B0699F2C2E88637D0098A042 /* RefreshControl */,
B058628F2EACBD8900E87706 /* ChatUI */,
);
productName = Development;
productReference = 4B26A6772A33239500B75FB4 /* Development.app */;
Expand Down Expand Up @@ -232,6 +238,7 @@
4BD04C172B2C13BB00FE41D9 /* Logger.swift in Sources */,
4BD04C192B2C15E100FE41D9 /* Color.swift in Sources */,
4BEBA5682D3EC5A200BDE020 /* BookRerender.swift in Sources */,
B058628E2EACBD6300E87706 /* BookChat.swift in Sources */,
4B08E0AB2CF5805500B05999 /* BookScrollView.swift in Sources */,
4BC34FB12CDB1C0C00D22811 /* BookCollectionView.swift in Sources */,
4BD04C152B2C05C600FE41D9 /* BookPlainCollectionView.swift in Sources */,
Expand Down Expand Up @@ -486,6 +493,10 @@
package = 4BEAFA4A2A3CE48800478C59 /* XCRemoteSwiftPackageReference "swiftui-async-multiplex-image" */;
productName = "AsyncMultiplexImage-Nuke";
};
B058628F2EACBD8900E87706 /* ChatUI */ = {
isa = XCSwiftPackageProductDependency;
productName = ChatUI;
};
B0699F282E8863750098A042 /* SelectableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SelectableForEach;
Expand Down
147 changes: 147 additions & 0 deletions Development/Development/BookChat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import ChatUI
import Foundation
import SwiftUI

// MARK: - Previews

private enum MessageSender {
case me
case other
}

private struct PreviewMessage: Identifiable {
let id: UUID
let text: String
let sender: MessageSender

init(id: UUID = UUID(), text: String, sender: MessageSender = .other) {
self.id = id
self.text = text
self.sender = sender
}
}

struct MessageListPreviewContainer: View {
@State private var messages: [PreviewMessage] = [
PreviewMessage(text: "Hello, how are you?", sender: .other),
PreviewMessage(text: "I'm fine, thank you!", sender: .me),
PreviewMessage(text: "What about you?", sender: .other),
PreviewMessage(text: "I'm doing great, thanks for asking!", sender: .me),
]
@State private var isLoadingOlder = false
@State private var autoScrollToBottom = true
@State private var olderMessageCounter = 0
@State private var newMessageCounter = 0

private static let sampleTexts = [
"Hey, did you see that?",
"I totally agree with you",
"That's interesting!",
"Can you explain more?",
"I was thinking the same thing",
"Wow, really?",
"Let me check on that",
"Thanks for sharing",
"That makes sense",
"Good point!",
"I'll get back to you",
"Sounds good to me",
"Looking forward to it",
"Nice work!",
"Got it, thanks",
"Let's do this!",
"Perfect timing",
"I see what you mean",
"Absolutely!",
"That's amazing",
]

var body: some View {
VStack(spacing: 16) {
VStack(spacing: 8) {
Toggle("Auto-scroll to new messages", isOn: $autoScrollToBottom)
.font(.caption)

Text("Scroll up to load older messages")
.font(.caption)
.foregroundStyle(.secondary)
}

MessageList(
messages: messages,
autoScrollToBottom: $autoScrollToBottom,
onLoadOlderMessages: {
print("Loading older messages...")
try? await Task.sleep(for: .seconds(1))

// Add older messages at the beginning
// The scroll position will be automatically maintained
let newMessages = (0..<5).map { _ in
let randomText = Self.sampleTexts.randomElement() ?? "Message"
let sender: MessageSender = Bool.random() ? .me : .other
return PreviewMessage(text: randomText, sender: sender)
}
messages.insert(contentsOf: newMessages.reversed(), at: 0)
}
) { message in
Text(message.text)
.padding(12)
.background(message.sender == .me ? Color.green.opacity(0.2) : Color.blue.opacity(0.1))
.cornerRadius(8)
.frame(maxWidth: .infinity, alignment: message.sender == .me ? .trailing : .leading)
}

HStack(spacing: 12) {
Button("Add New Message") {
let randomText = Self.sampleTexts.randomElement() ?? "Message"
let sender: MessageSender = Bool.random() ? .me : .other
messages.append(PreviewMessage(text: randomText, sender: sender))
}
.buttonStyle(.borderedProminent)

Button("Add Old Message") {
count += 1
}
.buttonStyle(.bordered)

Button("Clear All", role: .destructive) {
messages.removeAll()
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding()
.task(id: count) {
let newMessages = (0..<10).map { _ in
let randomText = Self.sampleTexts.randomElement() ?? "Message"
let sender: MessageSender = Bool.random() ? .me : .other
return PreviewMessage(text: randomText, sender: sender)
}
try? await Task.sleep(for: .milliseconds(500))
messages.insert(contentsOf: newMessages.reversed(), at: 0)
}
}

@State var count: Int = 0
}

#Preview("Interactive Preview") {
MessageListPreviewContainer()
}

#Preview("Simple Preview") {
MessageList(messages: [
PreviewMessage(text: "Hello, how are you?", sender: .other),
PreviewMessage(text: "I'm fine, thank you!", sender: .me),
PreviewMessage(text: "What about you?", sender: .other),
PreviewMessage(text: "I'm doing great, thanks for asking!", sender: .me),
]) { message in
Text(message.text)
.padding(12)
.background(message.sender == .me ? Color.green.opacity(0.2) : Color.blue.opacity(0.1))
.cornerRadius(8)
.frame(maxWidth: .infinity, alignment: message.sender == .me ? .trailing : .leading)
}
.padding()
}
4 changes: 4 additions & 0 deletions Development/Development/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ struct ContentView: View {
NavigationView {

List {
NavigationLink("Chat") {
MessageListPreviewContainer()
}

NavigationLink("Variadic") {
BookVariadicView()
}
Expand Down
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ let package = Package(
name: "DynamicList",
targets: ["DynamicList"]
),
.library(
name: "ChatUI",
targets: ["ChatUI"]
),
.library(
name: "CollectionView",
targets: ["CollectionView"]
Expand Down Expand Up @@ -54,6 +58,12 @@ let package = Package(
"ScrollTracking",
]
),
.target(
name: "ChatUI",
dependencies: [
.product(name: "SwiftUIIntrospect", package: "swiftui-introspect")
]
),
.target(
name: "ScrollTracking",
dependencies: [
Expand Down
148 changes: 148 additions & 0 deletions Sources/ChatUI/ChatUI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//
// ChatUI.swift
// swiftui-list-support
//
// Created by Hiroshi Kimura on 2025/10/23.
//

import SwiftUI
import SwiftUIIntrospect
import Combine

/// # Spec
///
/// - `MessageList` is a generic, scrollable message list component that displays messages using a custom view builder.
/// - Keeps short lists anchored to the bottom of the scroll view.
/// - Supports loading older messages by scrolling up, with an optional loading indicator at the top.
///
/// ## Usage
///
/// ```swift
/// MessageList(messages: messages) { message in
/// Text(message.text)
/// .padding(12)
/// .background(Color.blue.opacity(0.1))
/// .cornerRadius(8)
/// }
/// ```
public struct MessageList<Message: Identifiable, Content: View>: View {

public let messages: [Message]
private let content: (Message) -> Content
private let autoScrollToBottom: Binding<Bool>?
private let onLoadOlderMessages: (@MainActor () async -> Void)?

/// Creates a simple message list without older message loading support.
///
/// - Parameters:
/// - messages: Array of messages to display. Must conform to `Identifiable`.
/// - content: A view builder that creates the view for each message.
public init(
messages: [Message],
@ViewBuilder content: @escaping (Message) -> Content
) {
self.messages = messages
self.content = content
self.autoScrollToBottom = nil
self.onLoadOlderMessages = nil
}

/// Creates a message list with older message loading support.
///
/// - Parameters:
/// - messages: Array of messages to display. Must conform to `Identifiable`.
/// - autoScrollToBottom: Optional binding that controls automatic scrolling to bottom when new messages are added.
/// - onLoadOlderMessages: Async closure called when user scrolls up to trigger loading older messages.
/// - content: A view builder that creates the view for each message.
public init(
messages: [Message],
autoScrollToBottom: Binding<Bool>? = nil,
onLoadOlderMessages: @escaping @MainActor () async -> Void,
@ViewBuilder content: @escaping (Message) -> Content
) {
self.messages = messages
self.content = content
self.autoScrollToBottom = autoScrollToBottom
self.onLoadOlderMessages = onLoadOlderMessages
}

public var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if onLoadOlderMessages != nil {
Section {
ForEach(messages) { message in
content(message)
.anchorPreference(
key: _VisibleMessagesPreference.self,
value: .bounds
) { anchor in
[_VisibleMessagePayload(messageId: AnyHashable(message.id), bounds: anchor)]
}
}
} header: {
ProgressView()
.frame(height: 40)
}
} else {
ForEach(messages) { message in
content(message)
.anchorPreference(
key: _VisibleMessagesPreference.self,
value: .bounds
) { anchor in
[_VisibleMessagePayload(messageId: AnyHashable(message.id), bounds: anchor)]
}
}
}
}
}
.overlayPreferenceValue(_VisibleMessagesPreference.self) { payloads in
GeometryReader { geometry in
let sorted = payloads
.map { payload in
let rect = geometry[payload.bounds]
return (id: payload.messageId, y: rect.minY)
}
.sorted { $0.y < $1.y }

VStack(alignment: .leading, spacing: 4) {
Text("Visible Messages: \(sorted.count)")
.font(.caption)
.fontWeight(.bold)

if let first = sorted.first {
Text("First: \(String(describing: first.id))")
.font(.caption2)
Text(" y=\(String(format: "%.1f", first.y))")
.font(.caption2)
.foregroundStyle(.secondary)
}

if let last = sorted.last {
Text("Last: \(String(describing: last.id))")
.font(.caption2)
Text(" y=\(String(format: "%.1f", last.y))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(Color.black.opacity(0.8))
.foregroundStyle(.white)
.cornerRadius(8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding()
}
}
.modifier(
_OlderMessagesLoadingModifier(
autoScrollToBottom: autoScrollToBottom,
onLoadOlderMessages: onLoadOlderMessages
)
)
}
}

}
Loading