From 9733a7464815c5e9f3271ab581007c41c77b12a1 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 23 Oct 2025 22:22:51 +0900 Subject: [PATCH 01/12] Update --- Package.swift | 9 +++++++++ Sources/ChatUI/ChatUI.swift | 7 +++++++ 2 files changed, 16 insertions(+) create mode 100644 Sources/ChatUI/ChatUI.swift diff --git a/Package.swift b/Package.swift index 144c469..63d6597 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,11 @@ let package = Package( "ScrollTracking", ] ), + .target( + name: "ChatUI", + dependencies: [ + ] + ), .target( name: "ScrollTracking", dependencies: [ diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift new file mode 100644 index 0000000..dc7f193 --- /dev/null +++ b/Sources/ChatUI/ChatUI.swift @@ -0,0 +1,7 @@ +// +// ChatUI.swift +// swiftui-list-support +// +// Created by Hiroshi Kimura on 2025/10/23. +// + From ea19e71f6d7c5d1623912ea32f1f46c5260abc27 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 23 Oct 2025 22:37:54 +0900 Subject: [PATCH 02/12] Update --- Sources/ChatUI/ChatUI.swift | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index dc7f193..4b49a9f 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -5,3 +5,60 @@ // Created by Hiroshi Kimura on 2025/10/23. // +import SwiftUI + +/// # Spec +/// +/// - `MessageList` renders every entry from `messages` as a padded, left-aligned bubble inside a vertical scroll view that keeps short lists anchored to the bottom. +/// - `MessageListPreviewContainer` provides sample data and hosts interactive controls for SwiftUI previews. +/// - Pressing `Add Message` appends a uniquely numbered placeholder to `messages`, allowing the preview to demonstrate dynamic updates. +public struct MessageList: View { + + public let messages: [String] + + public init(messages: [String]) { + self.messages = messages + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(messages, id: \.self) { message in + Text(message) + .padding(12) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .defaultScrollAnchor(.bottom) + } + +} + +private struct MessageListPreviewContainer: View { + @State private var messages: [String] = [ + "Hello, how are you?", + "I'm fine, thank you!", + "What about you?", + "I'm doing great, thanks for asking!", + ] + + var body: some View { + VStack(spacing: 16) { + MessageList(messages: messages) + Button("Add Message") { + let nextIndex = messages.count + 1 + messages.append("Additional message \(nextIndex)") + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding() + } +} + +#Preview("Interactive Preview") { + MessageListPreviewContainer() +} From d96b3399095d51e810d2bf2aa749274646732963 Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 24 Oct 2025 13:21:55 +0900 Subject: [PATCH 03/12] WIP --- Package.swift | 1 + Sources/ChatUI/ChatUI.swift | 295 +++++++++++++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index 63d6597..3b6b8db 100644 --- a/Package.swift +++ b/Package.swift @@ -61,6 +61,7 @@ let package = Package( .target( name: "ChatUI", dependencies: [ + .product(name: "SwiftUIIntrospect", package: "swiftui-introspect") ] ), .target( diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index 4b49a9f..f8f9cf1 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -6,37 +6,286 @@ // import SwiftUI +import SwiftUIIntrospect +import Combine + +#if canImport(UIKit) +import UIKit +#endif /// # Spec /// /// - `MessageList` renders every entry from `messages` as a padded, left-aligned bubble inside a vertical scroll view that keeps short lists anchored to the bottom. /// - `MessageListPreviewContainer` provides sample data and hosts interactive controls for SwiftUI previews. /// - Pressing `Add Message` appends a uniquely numbered placeholder to `messages`, allowing the preview to demonstrate dynamic updates. +/// - Supports loading older messages by scrolling up, with an optional loading indicator at the top. public struct MessageList: View { public let messages: [String] + private let isLoadingOlderMessages: Binding? + private let onLoadOlderMessages: (@MainActor () async -> Void)? public init(messages: [String]) { self.messages = messages + self.isLoadingOlderMessages = nil + self.onLoadOlderMessages = nil + } + + public init( + messages: [String], + isLoadingOlderMessages: Binding, + onLoadOlderMessages: @escaping @MainActor () async -> Void + ) { + self.messages = messages + self.isLoadingOlderMessages = isLoadingOlderMessages + self.onLoadOlderMessages = onLoadOlderMessages } public var body: some View { ScrollView { LazyVStack(spacing: 8) { - ForEach(messages, id: \.self) { message in - Text(message) - .padding(12) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) - .frame(maxWidth: .infinity, alignment: .leading) + if let isLoadingOlderMessages { + Section { + ForEach(messages, id: \.self) { message in + Text(message) + .padding(12) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } header: { + if isLoadingOlderMessages.wrappedValue { + ProgressView() + .frame(height: 40) + } + } + } else { + ForEach(messages, id: \.self) { message in + Text(message) + .padding(12) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + .frame(maxWidth: .infinity, alignment: .leading) + } } } } .defaultScrollAnchor(.bottom) + .modifier( + _OlderMessagesLoadingModifier( + isLoadingOlderMessages: isLoadingOlderMessages, + onLoadOlderMessages: onLoadOlderMessages + ) + ) } } +// MARK: - Private Implementation + +@MainActor +private final class _OlderMessagesLoadingController: ObservableObject { + var scrollViewSubscription: AnyCancellable? = nil + var currentLoadingTask: Task? = nil + + // For scroll position preservation + #if canImport(UIKit) + weak var scrollViewRef: UIScrollView? = nil + var contentOffsetObservation: NSKeyValueObservation? = nil + var contentSizeObservation: NSKeyValueObservation? = nil + var lastKnownContentOffset: CGFloat = 0 + var lastKnownContentHeight: CGFloat = 0 + #endif + + nonisolated init() {} +} + +private struct _OlderMessagesLoadingModifier: ViewModifier { + @StateObject var controller: _OlderMessagesLoadingController = .init() + + private let isLoadingOlderMessages: Binding? + private let onLoadOlderMessages: (@MainActor () async -> Void)? + private let leadingScreens: CGFloat = 1.0 + + nonisolated init( + isLoadingOlderMessages: Binding?, + onLoadOlderMessages: (@MainActor () async -> Void)? + ) { + self.isLoadingOlderMessages = isLoadingOlderMessages + self.onLoadOlderMessages = onLoadOlderMessages + } + + func body(content: Content) -> some View { + if isLoadingOlderMessages != nil, onLoadOlderMessages != nil { + if #available(iOS 18.0, macOS 15.0, *) { + #if canImport(UIKit) + 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 + #endif + } else { + #if canImport(UIKit) + 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 + #endif + } + } else { + content + } + } + + private func shouldTriggerLoading( + contentOffset: CGFloat, + boundsHeight: CGFloat, + contentHeight: CGFloat + ) -> Bool { + guard let isLoadingOlderMessages = isLoadingOlderMessages else { return false } + guard !isLoadingOlderMessages.wrappedValue else { return false } + guard controller.currentLoadingTask == nil else { return false } + + let triggerDistance = boundsHeight * leadingScreens + let distanceFromTop = contentOffset + + // Trigger when scrolling up and close to the top + return distanceFromTop <= triggerDistance + } + + #if canImport(UIKit) + @MainActor + private func setupScrollPositionPreservation(scrollView: UIScrollView) { + controller.scrollViewRef = scrollView + + // Clean up existing observations + controller.contentOffsetObservation?.invalidate() + controller.contentSizeObservation?.invalidate() + + // Monitor contentOffset to track current scroll position + controller.contentOffsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak controller] scrollView, _ in + MainActor.assumeIsolated { + guard let controller = controller else { return } + controller.lastKnownContentOffset = scrollView.contentOffset.y + } + } + + // Monitor contentSize to detect when content is added + controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { [weak controller] scrollView, change in + MainActor.assumeIsolated { + guard let controller = controller else { return } + guard let oldHeight = change.oldValue?.height else { return } + + let newHeight = scrollView.contentSize.height + let heightDiff = newHeight - oldHeight + + if heightDiff > 0 { + // Content was added + let savedOffset = controller.lastKnownContentOffset + let newOffset = savedOffset + heightDiff + + print("[ChatUI] contentSize increased: oldHeight=\(oldHeight), newHeight=\(newHeight), heightDiff=\(heightDiff)") + print("[ChatUI] adjusting offset from \(scrollView.contentOffset.y) to \(newOffset)") + + scrollView.setContentOffset(CGPoint(x: 0, y: newOffset), animated: false) + + print("[ChatUI] adjusted to \(scrollView.contentOffset.y)") + } + + controller.lastKnownContentHeight = newHeight + } + } + + // Initialize with current values + controller.lastKnownContentOffset = scrollView.contentOffset.y + controller.lastKnownContentHeight = scrollView.contentSize.height + print("[ChatUI] initialized: offset=\(scrollView.contentOffset.y), height=\(scrollView.contentSize.height)") + } + #endif + + @MainActor + private func trigger() { + guard let isLoadingOlderMessages = isLoadingOlderMessages else { return } + guard let onLoadOlderMessages = onLoadOlderMessages else { return } + guard !isLoadingOlderMessages.wrappedValue else { return } + guard controller.currentLoadingTask == nil else { return } + + let task = Task { @MainActor in + await withTaskCancellationHandler { + isLoadingOlderMessages.wrappedValue = true + print("[ChatUI] trigger: starting to load older messages") + await onLoadOlderMessages() + print("[ChatUI] trigger: finished loading older messages") + isLoadingOlderMessages.wrappedValue = false + + controller.currentLoadingTask = nil + } onCancel: { + Task { @MainActor in + isLoadingOlderMessages.wrappedValue = false + controller.currentLoadingTask = nil + } + } + + // Debounce to avoid rapid re-triggering + try? await Task.sleep(for: .seconds(0.1)) + } + + controller.currentLoadingTask = task + } +} + +// Helper struct for scroll geometry +private struct _GeometryInfo: Equatable { + let contentOffset: CGPoint + let contentSize: CGSize + let containerSize: CGSize +} + +// MARK: - Previews + private struct MessageListPreviewContainer: View { @State private var messages: [String] = [ "Hello, how are you?", @@ -44,10 +293,32 @@ private struct MessageListPreviewContainer: View { "What about you?", "I'm doing great, thanks for asking!", ] + @State private var isLoadingOlder = false + @State private var olderMessageCounter = 0 var body: some View { VStack(spacing: 16) { - MessageList(messages: messages) + Text("Scroll up to load older messages") + .font(.caption) + .foregroundStyle(.secondary) + + MessageList( + messages: messages, + isLoadingOlderMessages: $isLoadingOlder, + 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 { index in + olderMessageCounter -= 1 + return "Older message \(olderMessageCounter)" + } + messages.insert(contentsOf: newMessages.reversed(), at: 0) + } + ) + Button("Add Message") { let nextIndex = messages.count + 1 messages.append("Additional message \(nextIndex)") @@ -62,3 +333,13 @@ private struct MessageListPreviewContainer: View { #Preview("Interactive Preview") { MessageListPreviewContainer() } + +#Preview("Simple Preview") { + MessageList(messages: [ + "Hello, how are you?", + "I'm fine, thank you!", + "What about you?", + "I'm doing great, thanks for asking!", + ]) + .padding() +} From 3a0c725648a4fe1df92febf0112b27c4b8e156a5 Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 24 Oct 2025 16:08:01 +0900 Subject: [PATCH 04/12] WIP --- Sources/ChatUI/ChatUI.swift | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index f8f9cf1..1bdda05 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -97,6 +97,9 @@ private final class _OlderMessagesLoadingController: ObservableObject { var lastKnownContentHeight: CGFloat = 0 #endif + // For scroll direction detection + var previousContentOffset: CGFloat? = nil + nonisolated init() {} } @@ -189,11 +192,33 @@ private struct _OlderMessagesLoadingModifier: ViewModifier { guard !isLoadingOlderMessages.wrappedValue 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 - // Trigger when scrolling up and close to the top - return distanceFromTop <= triggerDistance + let shouldTrigger = distanceFromTop <= triggerDistance + + if shouldTrigger { + print("[ChatUI] shouldTrigger: scrolling up, will trigger (offset: \(contentOffset), distance from top: \(distanceFromTop))") + } + + return shouldTrigger } #if canImport(UIKit) @@ -230,7 +255,7 @@ private struct _OlderMessagesLoadingModifier: ViewModifier { print("[ChatUI] contentSize increased: oldHeight=\(oldHeight), newHeight=\(newHeight), heightDiff=\(heightDiff)") print("[ChatUI] adjusting offset from \(scrollView.contentOffset.y) to \(newOffset)") - scrollView.setContentOffset(CGPoint(x: 0, y: newOffset), animated: false) + scrollView.contentOffset.y = newOffset print("[ChatUI] adjusted to \(scrollView.contentOffset.y)") } From e68630412807e3fcc2f6cfe9d494aa2fc26d4cb1 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sat, 25 Oct 2025 01:37:03 +0900 Subject: [PATCH 05/12] Update --- Sources/ChatUI/ChatUI.swift | 267 ++---------------- .../OlderMessagesLoadingController.swift | 31 ++ .../OlderMessagesLoadingModifier.swift | 238 ++++++++++++++++ 3 files changed, 297 insertions(+), 239 deletions(-) create mode 100644 Sources/ChatUI/Internal/OlderMessagesLoadingController.swift create mode 100644 Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index 1bdda05..f6a145f 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -9,10 +9,6 @@ import SwiftUI import SwiftUIIntrospect import Combine -#if canImport(UIKit) -import UIKit -#endif - /// # Spec /// /// - `MessageList` renders every entry from `messages` as a padded, left-aligned bubble inside a vertical scroll view that keeps short lists anchored to the bottom. @@ -23,21 +19,25 @@ public struct MessageList: View { public let messages: [String] private let isLoadingOlderMessages: Binding? + private let autoScrollToBottom: Binding? private let onLoadOlderMessages: (@MainActor () async -> Void)? public init(messages: [String]) { self.messages = messages self.isLoadingOlderMessages = nil + self.autoScrollToBottom = nil self.onLoadOlderMessages = nil } public init( messages: [String], isLoadingOlderMessages: Binding, + autoScrollToBottom: Binding? = nil, onLoadOlderMessages: @escaping @MainActor () async -> Void ) { self.messages = messages self.isLoadingOlderMessages = isLoadingOlderMessages + self.autoScrollToBottom = autoScrollToBottom self.onLoadOlderMessages = onLoadOlderMessages } @@ -74,6 +74,7 @@ public struct MessageList: View { .modifier( _OlderMessagesLoadingModifier( isLoadingOlderMessages: isLoadingOlderMessages, + autoScrollToBottom: autoScrollToBottom, onLoadOlderMessages: onLoadOlderMessages ) ) @@ -81,234 +82,6 @@ public struct MessageList: View { } -// MARK: - Private Implementation - -@MainActor -private final class _OlderMessagesLoadingController: ObservableObject { - var scrollViewSubscription: AnyCancellable? = nil - var currentLoadingTask: Task? = nil - - // For scroll position preservation - #if canImport(UIKit) - weak var scrollViewRef: UIScrollView? = nil - var contentOffsetObservation: NSKeyValueObservation? = nil - var contentSizeObservation: NSKeyValueObservation? = nil - var lastKnownContentOffset: CGFloat = 0 - var lastKnownContentHeight: CGFloat = 0 - #endif - - // For scroll direction detection - var previousContentOffset: CGFloat? = nil - - nonisolated init() {} -} - -private struct _OlderMessagesLoadingModifier: ViewModifier { - @StateObject var controller: _OlderMessagesLoadingController = .init() - - private let isLoadingOlderMessages: Binding? - private let onLoadOlderMessages: (@MainActor () async -> Void)? - private let leadingScreens: CGFloat = 1.0 - - nonisolated init( - isLoadingOlderMessages: Binding?, - onLoadOlderMessages: (@MainActor () async -> Void)? - ) { - self.isLoadingOlderMessages = isLoadingOlderMessages - self.onLoadOlderMessages = onLoadOlderMessages - } - - func body(content: Content) -> some View { - if isLoadingOlderMessages != nil, onLoadOlderMessages != nil { - if #available(iOS 18.0, macOS 15.0, *) { - #if canImport(UIKit) - 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 - #endif - } else { - #if canImport(UIKit) - 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 - #endif - } - } else { - content - } - } - - private func shouldTriggerLoading( - contentOffset: CGFloat, - boundsHeight: CGFloat, - contentHeight: CGFloat - ) -> Bool { - guard let isLoadingOlderMessages = isLoadingOlderMessages else { return false } - guard !isLoadingOlderMessages.wrappedValue 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 - - if shouldTrigger { - print("[ChatUI] shouldTrigger: scrolling up, will trigger (offset: \(contentOffset), distance from top: \(distanceFromTop))") - } - - return shouldTrigger - } - - #if canImport(UIKit) - @MainActor - private func setupScrollPositionPreservation(scrollView: UIScrollView) { - controller.scrollViewRef = scrollView - - // Clean up existing observations - controller.contentOffsetObservation?.invalidate() - controller.contentSizeObservation?.invalidate() - - // Monitor contentOffset to track current scroll position - controller.contentOffsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak controller] scrollView, _ in - MainActor.assumeIsolated { - guard let controller = controller else { return } - controller.lastKnownContentOffset = scrollView.contentOffset.y - } - } - - // Monitor contentSize to detect when content is added - controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { [weak controller] scrollView, change in - MainActor.assumeIsolated { - guard let controller = controller else { return } - guard let oldHeight = change.oldValue?.height else { return } - - let newHeight = scrollView.contentSize.height - let heightDiff = newHeight - oldHeight - - if heightDiff > 0 { - // Content was added - let savedOffset = controller.lastKnownContentOffset - let newOffset = savedOffset + heightDiff - - print("[ChatUI] contentSize increased: oldHeight=\(oldHeight), newHeight=\(newHeight), heightDiff=\(heightDiff)") - print("[ChatUI] adjusting offset from \(scrollView.contentOffset.y) to \(newOffset)") - - scrollView.contentOffset.y = newOffset - - print("[ChatUI] adjusted to \(scrollView.contentOffset.y)") - } - - controller.lastKnownContentHeight = newHeight - } - } - - // Initialize with current values - controller.lastKnownContentOffset = scrollView.contentOffset.y - controller.lastKnownContentHeight = scrollView.contentSize.height - print("[ChatUI] initialized: offset=\(scrollView.contentOffset.y), height=\(scrollView.contentSize.height)") - } - #endif - - @MainActor - private func trigger() { - guard let isLoadingOlderMessages = isLoadingOlderMessages else { return } - guard let onLoadOlderMessages = onLoadOlderMessages else { return } - guard !isLoadingOlderMessages.wrappedValue else { return } - guard controller.currentLoadingTask == nil else { return } - - let task = Task { @MainActor in - await withTaskCancellationHandler { - isLoadingOlderMessages.wrappedValue = true - print("[ChatUI] trigger: starting to load older messages") - await onLoadOlderMessages() - print("[ChatUI] trigger: finished loading older messages") - isLoadingOlderMessages.wrappedValue = false - - controller.currentLoadingTask = nil - } onCancel: { - Task { @MainActor in - isLoadingOlderMessages.wrappedValue = false - controller.currentLoadingTask = nil - } - } - - // Debounce to avoid rapid re-triggering - try? await Task.sleep(for: .seconds(0.1)) - } - - controller.currentLoadingTask = task - } -} - -// Helper struct for scroll geometry -private struct _GeometryInfo: Equatable { - let contentOffset: CGPoint - let contentSize: CGSize - let containerSize: CGSize -} - // MARK: - Previews private struct MessageListPreviewContainer: View { @@ -319,17 +92,25 @@ private struct MessageListPreviewContainer: View { "I'm doing great, thanks for asking!", ] @State private var isLoadingOlder = false + @State private var autoScrollToBottom = true @State private var olderMessageCounter = 0 + @State private var newMessageCounter = 0 var body: some View { VStack(spacing: 16) { - Text("Scroll up to load older messages") - .font(.caption) - .foregroundStyle(.secondary) + 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, isLoadingOlderMessages: $isLoadingOlder, + autoScrollToBottom: $autoScrollToBottom, onLoadOlderMessages: { print("Loading older messages...") try? await Task.sleep(for: .seconds(1)) @@ -344,11 +125,19 @@ private struct MessageListPreviewContainer: View { } ) - Button("Add Message") { - let nextIndex = messages.count + 1 - messages.append("Additional message \(nextIndex)") + HStack(spacing: 12) { + Button("Add New Message") { + newMessageCounter += 1 + messages.append("New message \(newMessageCounter)") + } + .buttonStyle(.borderedProminent) + + Button("Add Old Message (Bottom)") { + let nextIndex = messages.count + 1 + messages.append("Additional message \(nextIndex)") + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity, alignment: .trailing) } .padding() diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift new file mode 100644 index 0000000..74bb3a5 --- /dev/null +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -0,0 +1,31 @@ +// +// 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 position preservation + weak var scrollViewRef: UIScrollView? = nil + var contentOffsetObservation: NSKeyValueObservation? = nil + var contentSizeObservation: NSKeyValueObservation? = nil + var lastKnownContentOffset: CGFloat = 0 + var lastKnownContentHeight: CGFloat = 0 + + // For scroll direction detection + var previousContentOffset: CGFloat? = nil + + // For auto-scroll to bottom + var autoScrollToBottom: Bool = false + + nonisolated init() {} +} diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift new file mode 100644 index 0000000..6f5a28d --- /dev/null +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -0,0 +1,238 @@ +// +// 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 isLoadingOlderMessages: Binding? + private let autoScrollToBottom: Binding? + private let onLoadOlderMessages: (@MainActor () async -> Void)? + private let leadingScreens: CGFloat = 1.0 + + nonisolated init( + isLoadingOlderMessages: Binding?, + autoScrollToBottom: Binding?, + onLoadOlderMessages: (@MainActor () async -> Void)? + ) { + self.isLoadingOlderMessages = isLoadingOlderMessages + self.autoScrollToBottom = autoScrollToBottom + self.onLoadOlderMessages = onLoadOlderMessages + } + + func body(content: Content) -> some View { + if isLoadingOlderMessages != nil, 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 func shouldTriggerLoading( + contentOffset: CGFloat, + boundsHeight: CGFloat, + contentHeight: CGFloat + ) -> Bool { + guard let isLoadingOlderMessages = isLoadingOlderMessages else { return false } + guard !isLoadingOlderMessages.wrappedValue 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 + + if shouldTrigger { + print("[ChatUI] shouldTrigger: scrolling up, will trigger (offset: \(contentOffset), distance from top: \(distanceFromTop))") + } + + return shouldTrigger + } + + @MainActor + private func setupScrollPositionPreservation(scrollView: UIScrollView) { + controller.scrollViewRef = scrollView + + // Update autoScrollToBottom from binding + if let autoScrollToBottom = autoScrollToBottom { + controller.autoScrollToBottom = autoScrollToBottom.wrappedValue + } + + // Clean up existing observations + controller.contentOffsetObservation?.invalidate() + controller.contentSizeObservation?.invalidate() + + // Monitor contentOffset to track current scroll position + controller.contentOffsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak controller] scrollView, _ in + MainActor.assumeIsolated { + guard let controller = controller else { return } + controller.lastKnownContentOffset = scrollView.contentOffset.y + } + } + + // Monitor contentSize to detect when content is added + controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { [weak controller] scrollView, change in + MainActor.assumeIsolated { + guard let controller = controller else { return } + guard let oldHeight = change.oldValue?.height else { return } + + let newHeight = scrollView.contentSize.height + let heightDiff = newHeight - oldHeight + + if heightDiff > 0 { + // Update autoScrollToBottom value + if let autoScrollToBottom = autoScrollToBottom { + controller.autoScrollToBottom = autoScrollToBottom.wrappedValue + } + + let savedOffset = controller.lastKnownContentOffset + let boundsHeight = scrollView.bounds.height + + // Determine if user is near bottom (within 1 screen height) + let distanceFromBottom = oldHeight - savedOffset - boundsHeight + let isNearBottom = distanceFromBottom <= boundsHeight + + print("[ChatUI] contentSize increased: oldHeight=\(oldHeight), newHeight=\(newHeight), heightDiff=\(heightDiff)") + print("[ChatUI] user position: savedOffset=\(savedOffset), distanceFromBottom=\(distanceFromBottom), isNearBottom=\(isNearBottom)") + + // Case 1: User is viewing old messages (not near bottom) → preserve scroll position + if !isNearBottom { + let newOffset = savedOffset + heightDiff + print("[ChatUI] preserving scroll position (older messages added or user viewing history)") + scrollView.contentOffset.y = newOffset + } + // Case 2: User is near bottom + autoScroll enabled → scroll to bottom + else if controller.autoScrollToBottom { + let bottomOffset = newHeight - boundsHeight + print("[ChatUI] auto-scrolling to bottom (new message added, autoScroll=true)") + + UIView.animate(withDuration: 0.3) { + scrollView.contentOffset.y = max(0, bottomOffset) + } + } + // Case 3: User is near bottom but autoScroll disabled → do nothing + else { + print("[ChatUI] staying at current position (new message added, autoScroll=false)") + } + } + + controller.lastKnownContentHeight = newHeight + } + } + + // Initialize with current values + controller.lastKnownContentOffset = scrollView.contentOffset.y + controller.lastKnownContentHeight = scrollView.contentSize.height + print("[ChatUI] initialized: offset=\(scrollView.contentOffset.y), height=\(scrollView.contentSize.height), autoScroll=\(controller.autoScrollToBottom)") + } + + @MainActor + private func trigger() { + guard let isLoadingOlderMessages = isLoadingOlderMessages else { return } + guard let onLoadOlderMessages = onLoadOlderMessages else { return } + guard !isLoadingOlderMessages.wrappedValue else { return } + guard controller.currentLoadingTask == nil else { return } + + let task = Task { @MainActor in + await withTaskCancellationHandler { + isLoadingOlderMessages.wrappedValue = true + print("[ChatUI] trigger: starting to load older messages") + await onLoadOlderMessages() + print("[ChatUI] trigger: finished loading older messages") + isLoadingOlderMessages.wrappedValue = false + + controller.currentLoadingTask = nil + } onCancel: { + Task { @MainActor in + isLoadingOlderMessages.wrappedValue = false + controller.currentLoadingTask = nil + } + } + + // Debounce to avoid rapid re-triggering + try? await Task.sleep(for: .seconds(0.1)) + } + + controller.currentLoadingTask = task + } +} + +// Helper struct for scroll geometry +struct _GeometryInfo: Equatable { + let contentOffset: CGPoint + let contentSize: CGSize + let containerSize: CGSize +} From 47d6594dcc16f12558f25a6374d217dd17c74837 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sat, 25 Oct 2025 19:40:01 +0900 Subject: [PATCH 06/12] WIP --- .../Development.xcodeproj/project.pbxproj | 11 ++ Development/Development/BookChat.swift | 116 +++++++++++++ Development/Development/ContentView.swift | 4 + Sources/ChatUI/ChatUI.swift | 154 ++++++------------ .../OlderMessagesLoadingController.swift | 10 -- .../OlderMessagesLoadingModifier.swift | 75 +-------- 6 files changed, 184 insertions(+), 186 deletions(-) create mode 100644 Development/Development/BookChat.swift 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..e4de618 --- /dev/null +++ b/Development/Development/BookChat.swift @@ -0,0 +1,116 @@ +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 + + 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, + isLoadingOlderMessages: $isLoadingOlder, + 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 { index in + olderMessageCounter -= 1 + let sender: MessageSender = index % 2 == 0 ? .me : .other + return PreviewMessage(text: "Older message \(olderMessageCounter)", 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") { + newMessageCounter += 1 + let sender: MessageSender = Bool.random() ? .me : .other + messages.append(PreviewMessage(text: "New message \(newMessageCounter)", sender: sender)) + } + .buttonStyle(.borderedProminent) + + Button("Add Old Message") { + olderMessageCounter -= 1 + let sender: MessageSender = Bool.random() ? .me : .other + messages.insert(PreviewMessage(text: "Old message \(olderMessageCounter)", sender: sender), at: 0) + } + .buttonStyle(.bordered) + + Button("Clear All", role: .destructive) { + messages.removeAll() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding() + } +} + +#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..3a5310b 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/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index f6a145f..5eab903 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -11,31 +11,61 @@ import Combine /// # Spec /// -/// - `MessageList` renders every entry from `messages` as a padded, left-aligned bubble inside a vertical scroll view that keeps short lists anchored to the bottom. -/// - `MessageListPreviewContainer` provides sample data and hosts interactive controls for SwiftUI previews. -/// - Pressing `Add Message` appends a uniquely numbered placeholder to `messages`, allowing the preview to demonstrate dynamic updates. +/// - `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. -public struct MessageList: View { - - public let messages: [String] +/// +/// ## 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 isLoadingOlderMessages: Binding? private let autoScrollToBottom: Binding? private let onLoadOlderMessages: (@MainActor () async -> Void)? - public init(messages: [String]) { + /// 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.isLoadingOlderMessages = nil 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`. + /// - isLoadingOlderMessages: Binding that indicates whether older messages are currently being loaded. + /// - 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: [String], + messages: [Message], isLoadingOlderMessages: Binding, autoScrollToBottom: Binding? = nil, - onLoadOlderMessages: @escaping @MainActor () async -> Void + onLoadOlderMessages: @escaping @MainActor () async -> Void, + @ViewBuilder content: @escaping (Message) -> Content ) { self.messages = messages + self.content = content self.isLoadingOlderMessages = isLoadingOlderMessages self.autoScrollToBottom = autoScrollToBottom self.onLoadOlderMessages = onLoadOlderMessages @@ -44,33 +74,27 @@ public struct MessageList: View { public var body: some View { ScrollView { LazyVStack(spacing: 8) { - if let isLoadingOlderMessages { + if isLoadingOlderMessages != nil { Section { - ForEach(messages, id: \.self) { message in - Text(message) - .padding(12) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) - .frame(maxWidth: .infinity, alignment: .leading) + ForEach(messages) { message in + content(message) } } header: { - if isLoadingOlderMessages.wrappedValue { - ProgressView() - .frame(height: 40) - } + ProgressView() + .frame(height: 40) + .opacity(isLoadingOlderMessages?.wrappedValue == true ? 1.0 : 0.0) } } else { - ForEach(messages, id: \.self) { message in - Text(message) - .padding(12) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) - .frame(maxWidth: .infinity, alignment: .leading) + ForEach(messages) { message in + content(message) } } } } - .defaultScrollAnchor(.bottom) + /** + This is a trick to keep the scroll position when new items are inserted at the top. + */ + .contentMargins(.top, -0.5) .modifier( _OlderMessagesLoadingModifier( isLoadingOlderMessages: isLoadingOlderMessages, @@ -81,79 +105,3 @@ public struct MessageList: View { } } - -// MARK: - Previews - -private struct MessageListPreviewContainer: View { - @State private var messages: [String] = [ - "Hello, how are you?", - "I'm fine, thank you!", - "What about you?", - "I'm doing great, thanks for asking!", - ] - @State private var isLoadingOlder = false - @State private var autoScrollToBottom = true - @State private var olderMessageCounter = 0 - @State private var newMessageCounter = 0 - - 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, - isLoadingOlderMessages: $isLoadingOlder, - 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 { index in - olderMessageCounter -= 1 - return "Older message \(olderMessageCounter)" - } - messages.insert(contentsOf: newMessages.reversed(), at: 0) - } - ) - - HStack(spacing: 12) { - Button("Add New Message") { - newMessageCounter += 1 - messages.append("New message \(newMessageCounter)") - } - .buttonStyle(.borderedProminent) - - Button("Add Old Message (Bottom)") { - let nextIndex = messages.count + 1 - messages.append("Additional message \(nextIndex)") - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding() - } -} - -#Preview("Interactive Preview") { - MessageListPreviewContainer() -} - -#Preview("Simple Preview") { - MessageList(messages: [ - "Hello, how are you?", - "I'm fine, thank you!", - "What about you?", - "I'm doing great, thanks for asking!", - ]) - .padding() -} diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift index 74bb3a5..2eb54ec 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -14,18 +14,8 @@ final class _OlderMessagesLoadingController: ObservableObject { var scrollViewSubscription: AnyCancellable? = nil var currentLoadingTask: Task? = nil - // For scroll position preservation - weak var scrollViewRef: UIScrollView? = nil - var contentOffsetObservation: NSKeyValueObservation? = nil - var contentSizeObservation: NSKeyValueObservation? = nil - var lastKnownContentOffset: CGFloat = 0 - var lastKnownContentHeight: CGFloat = 0 - // For scroll direction detection var previousContentOffset: CGFloat? = nil - // For auto-scroll to bottom - var autoScrollToBottom: Bool = false - nonisolated init() {} } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift index 6f5a28d..c2bbb9f 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -124,79 +124,8 @@ struct _OlderMessagesLoadingModifier: ViewModifier { @MainActor private func setupScrollPositionPreservation(scrollView: UIScrollView) { - controller.scrollViewRef = scrollView - - // Update autoScrollToBottom from binding - if let autoScrollToBottom = autoScrollToBottom { - controller.autoScrollToBottom = autoScrollToBottom.wrappedValue - } - - // Clean up existing observations - controller.contentOffsetObservation?.invalidate() - controller.contentSizeObservation?.invalidate() - - // Monitor contentOffset to track current scroll position - controller.contentOffsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak controller] scrollView, _ in - MainActor.assumeIsolated { - guard let controller = controller else { return } - controller.lastKnownContentOffset = scrollView.contentOffset.y - } - } - - // Monitor contentSize to detect when content is added - controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { [weak controller] scrollView, change in - MainActor.assumeIsolated { - guard let controller = controller else { return } - guard let oldHeight = change.oldValue?.height else { return } - - let newHeight = scrollView.contentSize.height - let heightDiff = newHeight - oldHeight - - if heightDiff > 0 { - // Update autoScrollToBottom value - if let autoScrollToBottom = autoScrollToBottom { - controller.autoScrollToBottom = autoScrollToBottom.wrappedValue - } - - let savedOffset = controller.lastKnownContentOffset - let boundsHeight = scrollView.bounds.height - - // Determine if user is near bottom (within 1 screen height) - let distanceFromBottom = oldHeight - savedOffset - boundsHeight - let isNearBottom = distanceFromBottom <= boundsHeight - - print("[ChatUI] contentSize increased: oldHeight=\(oldHeight), newHeight=\(newHeight), heightDiff=\(heightDiff)") - print("[ChatUI] user position: savedOffset=\(savedOffset), distanceFromBottom=\(distanceFromBottom), isNearBottom=\(isNearBottom)") - - // Case 1: User is viewing old messages (not near bottom) → preserve scroll position - if !isNearBottom { - let newOffset = savedOffset + heightDiff - print("[ChatUI] preserving scroll position (older messages added or user viewing history)") - scrollView.contentOffset.y = newOffset - } - // Case 2: User is near bottom + autoScroll enabled → scroll to bottom - else if controller.autoScrollToBottom { - let bottomOffset = newHeight - boundsHeight - print("[ChatUI] auto-scrolling to bottom (new message added, autoScroll=true)") - - UIView.animate(withDuration: 0.3) { - scrollView.contentOffset.y = max(0, bottomOffset) - } - } - // Case 3: User is near bottom but autoScroll disabled → do nothing - else { - print("[ChatUI] staying at current position (new message added, autoScroll=false)") - } - } - - controller.lastKnownContentHeight = newHeight - } - } - - // Initialize with current values - controller.lastKnownContentOffset = scrollView.contentOffset.y - controller.lastKnownContentHeight = scrollView.contentSize.height - print("[ChatUI] initialized: offset=\(scrollView.contentOffset.y), height=\(scrollView.contentSize.height), autoScroll=\(controller.autoScrollToBottom)") + // Scroll position preservation is now handled by .contentMargins(.top, -0.5) + // This method is kept for potential future use of autoScrollToBottom feature } @MainActor From cee4d18eea22e9530c375406e00ab77ab2ed997e Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 26 Oct 2025 02:00:29 +0900 Subject: [PATCH 07/12] Update --- Development/Development/BookChat.swift | 53 +++++++++++++++---- Sources/ChatUI/ChatUI.swift | 44 ++++++++------- .../OlderMessagesLoadingController.swift | 6 +++ .../OlderMessagesLoadingModifier.swift | 45 +++++++++++++++- 4 files changed, 112 insertions(+), 36 deletions(-) diff --git a/Development/Development/BookChat.swift b/Development/Development/BookChat.swift index e4de618..5fe1454 100644 --- a/Development/Development/BookChat.swift +++ b/Development/Development/BookChat.swift @@ -33,6 +33,29 @@ struct MessageListPreviewContainer: View { @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) { @@ -52,12 +75,12 @@ struct MessageListPreviewContainer: View { 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 { index in - olderMessageCounter -= 1 - let sender: MessageSender = index % 2 == 0 ? .me : .other - return PreviewMessage(text: "Older message \(olderMessageCounter)", sender: sender) + // 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) } @@ -71,16 +94,14 @@ struct MessageListPreviewContainer: View { HStack(spacing: 12) { Button("Add New Message") { - newMessageCounter += 1 + let randomText = Self.sampleTexts.randomElement() ?? "Message" let sender: MessageSender = Bool.random() ? .me : .other - messages.append(PreviewMessage(text: "New message \(newMessageCounter)", sender: sender)) + messages.append(PreviewMessage(text: randomText, sender: sender)) } .buttonStyle(.borderedProminent) Button("Add Old Message") { - olderMessageCounter -= 1 - let sender: MessageSender = Bool.random() ? .me : .other - messages.insert(PreviewMessage(text: "Old message \(olderMessageCounter)", sender: sender), at: 0) + count += 1 } .buttonStyle(.bordered) @@ -92,7 +113,17 @@ struct MessageListPreviewContainer: View { .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) + } + messages.insert(contentsOf: newMessages.reversed(), at: 0) + } } + + @State var count: Int = 0 } #Preview("Interactive Preview") { diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index 5eab903..a1a91d3 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -72,36 +72,34 @@ public struct MessageList: View { } public var body: some View { - ScrollView { - LazyVStack(spacing: 8) { - if isLoadingOlderMessages != nil { - Section { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 8) { + if isLoadingOlderMessages != nil { + Section { + ForEach(messages) { message in + content(message) + } + } header: { + ProgressView() + .frame(height: 40) +// .opacity(isLoadingOlderMessages?.wrappedValue == true ? 1.0 : 0.0) + } + } else { ForEach(messages) { message in content(message) } - } header: { - ProgressView() - .frame(height: 40) - .opacity(isLoadingOlderMessages?.wrappedValue == true ? 1.0 : 0.0) - } - } else { - ForEach(messages) { message in - content(message) } } } - } - /** - This is a trick to keep the scroll position when new items are inserted at the top. - */ - .contentMargins(.top, -0.5) - .modifier( - _OlderMessagesLoadingModifier( - isLoadingOlderMessages: isLoadingOlderMessages, - autoScrollToBottom: autoScrollToBottom, - onLoadOlderMessages: onLoadOlderMessages + .modifier( + _OlderMessagesLoadingModifier( + isLoadingOlderMessages: isLoadingOlderMessages, + autoScrollToBottom: autoScrollToBottom, + onLoadOlderMessages: onLoadOlderMessages + ) ) - ) + } } } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift index 2eb54ec..fd9adb5 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -17,5 +17,11 @@ final class _OlderMessagesLoadingController: ObservableObject { // For scroll direction detection var previousContentOffset: CGFloat? = nil + // For scroll position preservation + weak var scrollViewRef: UIScrollView? = nil + var contentSizeObservation: NSKeyValueObservation? = nil + var lastKnownContentHeight: CGFloat = 0 + var lastKnownContentOffset: CGFloat = 0 + nonisolated init() {} } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift index c2bbb9f..3298932 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -124,8 +124,49 @@ struct _OlderMessagesLoadingModifier: ViewModifier { @MainActor private func setupScrollPositionPreservation(scrollView: UIScrollView) { - // Scroll position preservation is now handled by .contentMargins(.top, -0.5) - // This method is kept for potential future use of autoScrollToBottom feature + controller.scrollViewRef = scrollView + + // Clean up existing observations + controller.contentSizeObservation?.invalidate() + + // Monitor contentSize to detect when content is added + controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { + [weak controller] scrollView, change in + MainActor.assumeIsolated { + guard let controller = controller else { return } + 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: autoScrollToBottom enabled → scroll to bottom + if let autoScrollToBottom = autoScrollToBottom, + autoScrollToBottom.wrappedValue { + let bottomOffset = newHeight - boundsHeight + UIView.animate(withDuration: 0.3) { + scrollView.contentOffset.y = max(0, bottomOffset) + } + } + // Case 2: User is viewing history → preserve scroll position + else { + let newOffset = currentOffset + heightDiff + scrollView.contentOffset.y = newOffset + } + } + + controller.lastKnownContentHeight = newHeight + controller.lastKnownContentOffset = scrollView.contentOffset.y + } + } + + // Initialize with current values + controller.lastKnownContentHeight = scrollView.contentSize.height + controller.lastKnownContentOffset = scrollView.contentOffset.y } @MainActor From c44429028ba36711814c8e37b350db10c33385ec Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 26 Oct 2025 03:15:36 +0900 Subject: [PATCH 08/12] Update --- Development/Development/ContentView.swift | 2 +- Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Development/Development/ContentView.swift b/Development/Development/ContentView.swift index 3a5310b..852d12b 100644 --- a/Development/Development/ContentView.swift +++ b/Development/Development/ContentView.swift @@ -16,7 +16,7 @@ struct ContentView: View { NavigationLink("Chat") { MessageListPreviewContainer() } - + NavigationLink("Variadic") { BookVariadicView() } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift index 3298932..7237cef 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -124,8 +124,9 @@ struct _OlderMessagesLoadingModifier: ViewModifier { @MainActor private func setupScrollPositionPreservation(scrollView: UIScrollView) { + controller.scrollViewRef = scrollView - + // Clean up existing observations controller.contentSizeObservation?.invalidate() From cbf38027a4b2a3cb0f435c25c0ab4abc6e984755 Mon Sep 17 00:00:00 2001 From: Muukii Date: Mon, 27 Oct 2025 00:35:31 +0900 Subject: [PATCH 09/12] WIP --- Development/Development/BookChat.swift | 1 + Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Development/Development/BookChat.swift b/Development/Development/BookChat.swift index 5fe1454..8b8d68d 100644 --- a/Development/Development/BookChat.swift +++ b/Development/Development/BookChat.swift @@ -119,6 +119,7 @@ struct MessageListPreviewContainer: View { 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) } } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift index 7237cef..7655277 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -124,13 +124,13 @@ struct _OlderMessagesLoadingModifier: ViewModifier { @MainActor private func setupScrollPositionPreservation(scrollView: UIScrollView) { - + controller.scrollViewRef = scrollView - + // Clean up existing observations controller.contentSizeObservation?.invalidate() - // Monitor contentSize to detect when content is added + // Monitor contentSize to detect when content is added (KVO) controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { [weak controller] scrollView, change in MainActor.assumeIsolated { From b96008b1b10a25c9b31ea28fb5a84a0025394fd9 Mon Sep 17 00:00:00 2001 From: Muukii Date: Mon, 27 Oct 2025 00:49:43 +0900 Subject: [PATCH 10/12] WIP --- Sources/ChatUI/ChatUI.swift | 50 +++++++++++++++++++ .../Internal/VisibleMessagesPreference.swift | 22 ++++++++ 2 files changed, 72 insertions(+) create mode 100644 Sources/ChatUI/Internal/VisibleMessagesPreference.swift diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index a1a91d3..e5dbffa 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -79,6 +79,12 @@ public struct MessageList: View { Section { ForEach(messages) { message in content(message) + .anchorPreference( + key: _VisibleMessagesPreference.self, + value: .bounds + ) { anchor in + [_VisibleMessagePayload(messageId: AnyHashable(message.id), bounds: anchor)] + } } } header: { ProgressView() @@ -88,10 +94,54 @@ public struct MessageList: View { } 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( isLoadingOlderMessages: isLoadingOlderMessages, 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()) + } +} + From b6081ff7eda3a4c8aac4ee18c0f2b928d9d4ef59 Mon Sep 17 00:00:00 2001 From: Muukii Date: Mon, 27 Oct 2025 17:04:31 +0900 Subject: [PATCH 11/12] WIP --- .../Internal/OlderMessagesLoadingController.swift | 2 -- .../Internal/OlderMessagesLoadingModifier.swift | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift index fd9adb5..f4bef35 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -20,8 +20,6 @@ final class _OlderMessagesLoadingController: ObservableObject { // For scroll position preservation weak var scrollViewRef: UIScrollView? = nil var contentSizeObservation: NSKeyValueObservation? = nil - var lastKnownContentHeight: CGFloat = 0 - var lastKnownContentOffset: CGFloat = 0 nonisolated init() {} } diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift index 7655277..9eecf89 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -115,10 +115,6 @@ struct _OlderMessagesLoadingModifier: ViewModifier { let shouldTrigger = distanceFromTop <= triggerDistance - if shouldTrigger { - print("[ChatUI] shouldTrigger: scrolling up, will trigger (offset: \(contentOffset), distance from top: \(distanceFromTop))") - } - return shouldTrigger } @@ -159,15 +155,8 @@ struct _OlderMessagesLoadingModifier: ViewModifier { scrollView.contentOffset.y = newOffset } } - - controller.lastKnownContentHeight = newHeight - controller.lastKnownContentOffset = scrollView.contentOffset.y } } - - // Initialize with current values - controller.lastKnownContentHeight = scrollView.contentSize.height - controller.lastKnownContentOffset = scrollView.contentOffset.y } @MainActor @@ -180,9 +169,7 @@ struct _OlderMessagesLoadingModifier: ViewModifier { let task = Task { @MainActor in await withTaskCancellationHandler { isLoadingOlderMessages.wrappedValue = true - print("[ChatUI] trigger: starting to load older messages") await onLoadOlderMessages() - print("[ChatUI] trigger: finished loading older messages") isLoadingOlderMessages.wrappedValue = false controller.currentLoadingTask = nil From c3d5fe69fe8c553db04e68fa61df199c3a9562e0 Mon Sep 17 00:00:00 2001 From: Muukii Date: Mon, 27 Oct 2025 20:45:58 +0900 Subject: [PATCH 12/12] WIP --- Development/Development/BookChat.swift | 1 - Sources/ChatUI/ChatUI.swift | 9 +- .../OlderMessagesLoadingController.swift | 3 + .../OlderMessagesLoadingModifier.swift | 87 +++++++++++-------- 4 files changed, 55 insertions(+), 45 deletions(-) diff --git a/Development/Development/BookChat.swift b/Development/Development/BookChat.swift index 8b8d68d..039402a 100644 --- a/Development/Development/BookChat.swift +++ b/Development/Development/BookChat.swift @@ -69,7 +69,6 @@ struct MessageListPreviewContainer: View { MessageList( messages: messages, - isLoadingOlderMessages: $isLoadingOlder, autoScrollToBottom: $autoScrollToBottom, onLoadOlderMessages: { print("Loading older messages...") diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift index e5dbffa..86cf81a 100644 --- a/Sources/ChatUI/ChatUI.swift +++ b/Sources/ChatUI/ChatUI.swift @@ -29,7 +29,6 @@ public struct MessageList: View { public let messages: [Message] private let content: (Message) -> Content - private let isLoadingOlderMessages: Binding? private let autoScrollToBottom: Binding? private let onLoadOlderMessages: (@MainActor () async -> Void)? @@ -44,7 +43,6 @@ public struct MessageList: View { ) { self.messages = messages self.content = content - self.isLoadingOlderMessages = nil self.autoScrollToBottom = nil self.onLoadOlderMessages = nil } @@ -53,20 +51,17 @@ public struct MessageList: View { /// /// - Parameters: /// - messages: Array of messages to display. Must conform to `Identifiable`. - /// - isLoadingOlderMessages: Binding that indicates whether older messages are currently being loaded. /// - 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], - isLoadingOlderMessages: Binding, autoScrollToBottom: Binding? = nil, onLoadOlderMessages: @escaping @MainActor () async -> Void, @ViewBuilder content: @escaping (Message) -> Content ) { self.messages = messages self.content = content - self.isLoadingOlderMessages = isLoadingOlderMessages self.autoScrollToBottom = autoScrollToBottom self.onLoadOlderMessages = onLoadOlderMessages } @@ -75,7 +70,7 @@ public struct MessageList: View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 8) { - if isLoadingOlderMessages != nil { + if onLoadOlderMessages != nil { Section { ForEach(messages) { message in content(message) @@ -89,7 +84,6 @@ public struct MessageList: View { } header: { ProgressView() .frame(height: 40) -// .opacity(isLoadingOlderMessages?.wrappedValue == true ? 1.0 : 0.0) } } else { ForEach(messages) { message in @@ -144,7 +138,6 @@ public struct MessageList: View { } .modifier( _OlderMessagesLoadingModifier( - isLoadingOlderMessages: isLoadingOlderMessages, autoScrollToBottom: autoScrollToBottom, onLoadOlderMessages: onLoadOlderMessages ) diff --git a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift index f4bef35..e0af01b 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingController.swift @@ -21,5 +21,8 @@ final class _OlderMessagesLoadingController: ObservableObject { 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 index 9eecf89..a696aba 100644 --- a/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/ChatUI/Internal/OlderMessagesLoadingModifier.swift @@ -12,23 +12,20 @@ import UIKit struct _OlderMessagesLoadingModifier: ViewModifier { @StateObject var controller: _OlderMessagesLoadingController = .init() - private let isLoadingOlderMessages: Binding? private let autoScrollToBottom: Binding? private let onLoadOlderMessages: (@MainActor () async -> Void)? private let leadingScreens: CGFloat = 1.0 nonisolated init( - isLoadingOlderMessages: Binding?, autoScrollToBottom: Binding?, onLoadOlderMessages: (@MainActor () async -> Void)? ) { - self.isLoadingOlderMessages = isLoadingOlderMessages self.autoScrollToBottom = autoScrollToBottom self.onLoadOlderMessages = onLoadOlderMessages } func body(content: Content) -> some View { - if isLoadingOlderMessages != nil, onLoadOlderMessages != nil { + if onLoadOlderMessages != nil { if #available(iOS 18.0, macOS 15.0, *) { content .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in @@ -61,22 +58,24 @@ struct _OlderMessagesLoadingModifier: ViewModifier { controller.scrollViewSubscription?.cancel() - controller.scrollViewSubscription = scrollView.publisher(for: \.contentOffset) - .sink { [weak scrollView] offset in - guard let scrollView else { return } + 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 - ) + let triggers = shouldTriggerLoading( + contentOffset: offset.y, + boundsHeight: scrollView.bounds.height, + contentHeight: scrollView.contentSize.height + ) - if triggers { - Task { @MainActor in - trigger() - } + if triggers { + Task { @MainActor in + trigger() } } + } } } } else { @@ -84,13 +83,20 @@ struct _OlderMessagesLoadingModifier: ViewModifier { } } + 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 let isLoadingOlderMessages = isLoadingOlderMessages else { return false } - guard !isLoadingOlderMessages.wrappedValue else { return false } + guard !isBackwardLoading else { return false } guard controller.currentLoadingTask == nil else { return false } // Check scroll direction @@ -127,10 +133,11 @@ struct _OlderMessagesLoadingModifier: ViewModifier { controller.contentSizeObservation?.invalidate() // Monitor contentSize to detect when content is added (KVO) - controller.contentSizeObservation = scrollView.observe(\.contentSize, options: [.old, .new]) { - [weak controller] scrollView, change in + controller.contentSizeObservation = scrollView.observe( + \.contentSize, + options: [.old, .new] + ) { scrollView, change in MainActor.assumeIsolated { - guard let controller = controller else { return } guard let oldHeight = change.oldValue?.height else { return } let newHeight = scrollView.contentSize.height @@ -141,19 +148,21 @@ struct _OlderMessagesLoadingModifier: ViewModifier { let currentOffset = scrollView.contentOffset.y let boundsHeight = scrollView.bounds.height - // Case 1: autoScrollToBottom enabled → scroll to bottom - if let autoScrollToBottom = autoScrollToBottom, - autoScrollToBottom.wrappedValue { + // 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 2: User is viewing history → preserve scroll position - else { - let newOffset = currentOffset + heightDiff - scrollView.contentOffset.y = newOffset - } + // Case 3: Normal message addition → do nothing } } } @@ -161,27 +170,33 @@ struct _OlderMessagesLoadingModifier: ViewModifier { @MainActor private func trigger() { - guard let isLoadingOlderMessages = isLoadingOlderMessages else { return } + guard let onLoadOlderMessages = onLoadOlderMessages else { return } - guard !isLoadingOlderMessages.wrappedValue else { return } + + guard !isBackwardLoading else { return } + guard controller.currentLoadingTask == nil else { return } let task = Task { @MainActor in await withTaskCancellationHandler { - isLoadingOlderMessages.wrappedValue = true + setBackwardLoading(true) + await onLoadOlderMessages() - isLoadingOlderMessages.wrappedValue = false + + // 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 - isLoadingOlderMessages.wrappedValue = false + setBackwardLoading(false) controller.currentLoadingTask = nil } } - // Debounce to avoid rapid re-triggering - try? await Task.sleep(for: .seconds(0.1)) } controller.currentLoadingTask = task