diff --git a/Development/Development.xcodeproj/project.pbxproj b/Development/Development.xcodeproj/project.pbxproj index bbee0e1..45bb169 100644 --- a/Development/Development.xcodeproj/project.pbxproj +++ b/Development/Development.xcodeproj/project.pbxproj @@ -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 */; }; @@ -62,6 +64,7 @@ 4BD04C182B2C15E100FE41D9 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 4BEAFA482A3CE3B100478C59 /* BookVariadicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVariadicView.swift; sourceTree = ""; }; 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRerender.swift; sourceTree = ""; }; + B058628D2EACBD6100E87706 /* BookChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChat.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +72,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B05862902EACBD8900E87706 /* ChatUI in Frameworks */, 4B08E0AD2CF5947100B05999 /* ScrollTracking in Frameworks */, 4BC34FAF2CDB1B9200D22811 /* CollectionView in Frameworks */, 4BEAFA4E2A3CE48800478C59 /* AsyncMultiplexImage-Nuke in Frameworks */, @@ -105,6 +109,7 @@ 4B26A6792A33239500B75FB4 /* Development */ = { isa = PBXGroup; children = ( + B058628D2EACBD6100E87706 /* BookChat.swift */, 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */, 4B9223612D3CE6B5007E20CB /* GlobalCounter.swift */, 4B08E0AA2CF5805200B05999 /* BookScrollView.swift */, @@ -165,6 +170,7 @@ B0699F282E8863750098A042 /* SelectableForEach */, B0699F2A2E8863790098A042 /* StickyHeader */, B0699F2C2E88637D0098A042 /* RefreshControl */, + B058628F2EACBD8900E87706 /* ChatUI */, ); productName = Development; productReference = 4B26A6772A33239500B75FB4 /* Development.app */; @@ -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 */, @@ -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; diff --git a/Development/Development/BookChat.swift b/Development/Development/BookChat.swift new file mode 100644 index 0000000..039402a --- /dev/null +++ b/Development/Development/BookChat.swift @@ -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() +} diff --git a/Development/Development/ContentView.swift b/Development/Development/ContentView.swift index 938c9c3..852d12b 100644 --- a/Development/Development/ContentView.swift +++ b/Development/Development/ContentView.swift @@ -13,6 +13,10 @@ struct ContentView: View { NavigationView { List { + NavigationLink("Chat") { + MessageListPreviewContainer() + } + NavigationLink("Variadic") { BookVariadicView() } diff --git a/Package.swift b/Package.swift index 144c469..3b6b8db 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,10 @@ let package = Package( name: "DynamicList", targets: ["DynamicList"] ), + .library( + name: "ChatUI", + targets: ["ChatUI"] + ), .library( name: "CollectionView", targets: ["CollectionView"] @@ -54,6 +58,12 @@ let package = Package( "ScrollTracking", ] ), + .target( + name: "ChatUI", + dependencies: [ + .product(name: "SwiftUIIntrospect", package: "swiftui-introspect") + ] + ), .target( name: "ScrollTracking", dependencies: [ diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift new file mode 100644 index 0000000..86cf81a --- /dev/null +++ b/Sources/ChatUI/ChatUI.swift @@ -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: View { + + public let messages: [Message] + private let content: (Message) -> Content + private let autoScrollToBottom: Binding? + 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? = 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 + ) + ) + } + } + +} diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift new file mode 100644 index 0000000..e0af01b --- /dev/null +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -0,0 +1,28 @@ +// +// OlderMessagesLoadingController.swift +// swiftui-list-support +// +// Created by Hiroshi Kimura on 2025/10/23. +// + +import SwiftUI +import Combine +import UIKit + +@MainActor +final class _OlderMessagesLoadingController: ObservableObject { + var scrollViewSubscription: AnyCancellable? = nil + var currentLoadingTask: Task? = nil + + // For scroll direction detection + var previousContentOffset: CGFloat? = nil + + // For scroll position preservation + weak var scrollViewRef: UIScrollView? = nil + var contentSizeObservation: NSKeyValueObservation? = nil + + // Internal loading state (used when no external binding is provided) + var internalIsBackwardLoading: Bool = false + + nonisolated init() {} +} diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift new file mode 100644 index 0000000..a696aba --- /dev/null +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -0,0 +1,211 @@ +// +// OlderMessagesLoadingModifier.swift +// swiftui-list-support +// +// Created by Hiroshi Kimura on 2025/10/23. +// + +import SwiftUI +import SwiftUIIntrospect +import UIKit + +struct _OlderMessagesLoadingModifier: ViewModifier { + @StateObject var controller: _OlderMessagesLoadingController = .init() + + private let autoScrollToBottom: Binding? + private let onLoadOlderMessages: (@MainActor () async -> Void)? + private let leadingScreens: CGFloat = 1.0 + + nonisolated init( + autoScrollToBottom: Binding?, + onLoadOlderMessages: (@MainActor () async -> Void)? + ) { + self.autoScrollToBottom = autoScrollToBottom + self.onLoadOlderMessages = onLoadOlderMessages + } + + func body(content: Content) -> some View { + if onLoadOlderMessages != nil { + if #available(iOS 18.0, macOS 15.0, *) { + content + .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in + // Save reference and setup monitoring + setupScrollPositionPreservation(scrollView: scrollView) + } + .onScrollGeometryChange(for: _GeometryInfo.self) { geometry in + return _GeometryInfo( + contentOffset: geometry.contentOffset, + contentSize: geometry.contentSize, + containerSize: geometry.containerSize + ) + } action: { _, geometry in + let triggers = shouldTriggerLoading( + contentOffset: geometry.contentOffset.y, + boundsHeight: geometry.containerSize.height, + contentHeight: geometry.contentSize.height + ) + + if triggers { + Task { @MainActor in + trigger() + } + } + } + } else { + content.introspect(.scrollView, on: .iOS(.v17)) { scrollView in + // Save reference and setup monitoring + setupScrollPositionPreservation(scrollView: scrollView) + + controller.scrollViewSubscription?.cancel() + + controller.scrollViewSubscription = scrollView.publisher( + for: \.contentOffset + ) + .sink { [weak scrollView] offset in + guard let scrollView else { return } + + let triggers = shouldTriggerLoading( + contentOffset: offset.y, + boundsHeight: scrollView.bounds.height, + contentHeight: scrollView.contentSize.height + ) + + if triggers { + Task { @MainActor in + trigger() + } + } + } + } + } + } else { + content + } + } + + private var isBackwardLoading: Bool { + controller.internalIsBackwardLoading + } + + private func setBackwardLoading(_ value: Bool) { + controller.internalIsBackwardLoading = value + } + + private func shouldTriggerLoading( + contentOffset: CGFloat, + boundsHeight: CGFloat, + contentHeight: CGFloat + ) -> Bool { + guard !isBackwardLoading else { return false } + guard controller.currentLoadingTask == nil else { return false } + + // Check scroll direction + guard let previousOffset = controller.previousContentOffset else { + // First time - can't determine direction, just save and skip + controller.previousContentOffset = contentOffset + return false + } + + let isScrollingUp = contentOffset < previousOffset + + // Update previous offset for next comparison + controller.previousContentOffset = contentOffset + + // Only trigger when scrolling up (towards older messages) + guard isScrollingUp else { + return false + } + + let triggerDistance = boundsHeight * leadingScreens + let distanceFromTop = contentOffset + + let shouldTrigger = distanceFromTop <= triggerDistance + + return shouldTrigger + } + + @MainActor + private func setupScrollPositionPreservation(scrollView: UIScrollView) { + + controller.scrollViewRef = scrollView + + // Clean up existing observations + controller.contentSizeObservation?.invalidate() + + // Monitor contentSize to detect when content is added (KVO) + controller.contentSizeObservation = scrollView.observe( + \.contentSize, + options: [.old, .new] + ) { scrollView, change in + MainActor.assumeIsolated { + guard let oldHeight = change.oldValue?.height else { return } + + let newHeight = scrollView.contentSize.height + let heightDiff = newHeight - oldHeight + + // Content size increased + if heightDiff > 0 { + let currentOffset = scrollView.contentOffset.y + let boundsHeight = scrollView.bounds.height + + // Case 1: Loading older messages → preserve scroll position (highest priority) + if isBackwardLoading { + let newOffset = currentOffset + heightDiff + scrollView.contentOffset.y = newOffset + } + // Case 2: autoScrollToBottom enabled → scroll to bottom + else if let autoScrollToBottom = autoScrollToBottom, + autoScrollToBottom.wrappedValue + { + let bottomOffset = newHeight - boundsHeight + UIView.animate(withDuration: 0.3) { + scrollView.contentOffset.y = max(0, bottomOffset) + } + } + // Case 3: Normal message addition → do nothing + } + } + } + } + + @MainActor + private func trigger() { + + guard let onLoadOlderMessages = onLoadOlderMessages else { return } + + guard !isBackwardLoading else { return } + + guard controller.currentLoadingTask == nil else { return } + + let task = Task { @MainActor in + await withTaskCancellationHandler { + setBackwardLoading(true) + + await onLoadOlderMessages() + + // Debounce to avoid rapid re-triggering + // Ensure the UI has time to update + try? await Task.sleep(for: .milliseconds(100)) + + setBackwardLoading(false) + + controller.currentLoadingTask = nil + } onCancel: { + Task { @MainActor in + setBackwardLoading(false) + controller.currentLoadingTask = nil + } + } + + } + + controller.currentLoadingTask = task + } +} + +// Helper struct for scroll geometry +struct _GeometryInfo: Equatable { + let contentOffset: CGPoint + let contentSize: CGSize + let containerSize: CGSize +} diff --git a/Sources/ChatUI/Internal/VisibleMessagesPreference.swift b/Sources/ChatUI/Internal/VisibleMessagesPreference.swift new file mode 100644 index 0000000..d2878d0 --- /dev/null +++ b/Sources/ChatUI/Internal/VisibleMessagesPreference.swift @@ -0,0 +1,22 @@ +// +// VisibleMessagesPreference.swift +// swiftui-list-support +// +// Created by Hiroshi Kimura on 2025/10/27. +// + +import SwiftUI + +struct _VisibleMessagePayload { + var messageId: AnyHashable + var bounds: Anchor +} + +struct _VisibleMessagesPreference: PreferenceKey { + nonisolated(unsafe) static let defaultValue: [_VisibleMessagePayload] = [] + + static func reduce(value: inout [_VisibleMessagePayload], nextValue: () -> [_VisibleMessagePayload]) { + value.append(contentsOf: nextValue()) + } +} +