From 2b39ca0bc87961d38d639cbbc31b8d7d81a89859 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 17:50:26 +0900 Subject: [PATCH 01/27] WIP --- Dev/MessagingUIDevelopment/ContentView.swift | 5 +- .../TiledViewDemo.swift | 127 +++++++++++ .../Tiled/Demo/TiledViewDemo.swift | 126 +++++++++++ Sources/MessagingUI/Tiled/README.md | 198 ++++++++++++++++++ .../Tiled/TiledCollectionViewLayout.swift | 122 +++++++++++ Sources/MessagingUI/Tiled/TiledView.swift | 193 +++++++++++++++++ 6 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 Dev/MessagingUIDevelopment/TiledViewDemo.swift create mode 100644 Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift create mode 100644 Sources/MessagingUI/Tiled/README.md create mode 100644 Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift create mode 100644 Sources/MessagingUI/Tiled/TiledView.swift diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 1d8a10a..85b94df 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -14,11 +14,14 @@ struct ContentView: View { NavigationLink("Message List Preview") { MessageListPreviewContainer() } + NavigationLink("BookTiledView") { + BookTiledView() + } } } } } -#Preview { +#Preview { ContentView() } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift new file mode 100644 index 0000000..70ce7bd --- /dev/null +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -0,0 +1,127 @@ +// +// TiledViewDemo.swift +// TiledView +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import SwiftUI +import MessagingUI + +// MARK: - Sample Data + +struct ChatMessage: Identifiable, Hashable, Sendable { + let id: Int + let text: String +} + +private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { + let sampleTexts = [ + "こんにちは!", + "今日はいい天気ですね。散歩に行きませんか?", + "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", + "了解です👍", + "ちょっと待ってください。確認してから返信しますね。", + "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", + "OK", + "今から出発します!", + "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "🎉🎊✨", + ] + + return (0.. CGFloat { + let padding: CGFloat = 12 + let maxBubbleWidth = width - 60 + + let label = UILabel() + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 16) + label.text = message.text + + let labelSize = label.sizeThatFits(CGSize(width: maxBubbleWidth - padding * 2, height: .greatestFiniteMagnitude)) + return labelSize.height + padding * 2 + 16 +} + +// MARK: - Demo View + +struct BookTiledView: View { + + @State private var viewController: TiledViewController? + @State private var nextPrependId = -1 + @State private var nextAppendId = 20 + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Prepend 5") { + guard let viewController else { return } + let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) + viewController.prependItems(messages) + nextPrependId -= 5 + } + + Spacer() + + Button("Append 5") { + guard let viewController else { return } + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + viewController.appendItems(messages) + nextAppendId += 5 + } + } + .padding() + .background(Color(.systemBackground)) + + TiledViewRepresentable( + viewController: $viewController, + items: [], + cellBuilder: { message in + ChatBubbleView(message: message) + }, + heightCalculator: calculateCellHeight + ) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let initial = generateSampleMessages(count: 20, startId: 0) + viewController?.setItems(initial) + } + } + } + } +} + +#Preview("TiledView Demo") { + BookTiledView() +} diff --git a/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift b/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift new file mode 100644 index 0000000..472bef7 --- /dev/null +++ b/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift @@ -0,0 +1,126 @@ +// +// TiledViewDemo.swift +// TiledView +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import SwiftUI + +// MARK: - Sample Data + +struct ChatMessage: Identifiable, Hashable, Sendable { + let id: Int + let text: String +} + +private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { + let sampleTexts = [ + "こんにちは!", + "今日はいい天気ですね。散歩に行きませんか?", + "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", + "了解です👍", + "ちょっと待ってください。確認してから返信しますね。", + "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", + "OK", + "今から出発します!", + "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "🎉🎊✨", + ] + + return (0.. CGFloat { + let padding: CGFloat = 12 + let maxBubbleWidth = width - 60 + + let label = UILabel() + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 16) + label.text = message.text + + let labelSize = label.sizeThatFits(CGSize(width: maxBubbleWidth - padding * 2, height: .greatestFiniteMagnitude)) + return labelSize.height + padding * 2 + 16 +} + +// MARK: - Demo View + +struct BookTiledView: View { + + @State private var viewController: TiledViewController? + @State private var nextPrependId = -1 + @State private var nextAppendId = 20 + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Prepend 5") { + guard let viewController else { return } + let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) + viewController.prependItems(messages) + nextPrependId -= 5 + } + + Spacer() + + Button("Append 5") { + guard let viewController else { return } + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + viewController.appendItems(messages) + nextAppendId += 5 + } + } + .padding() + .background(Color(.systemBackground)) + + TiledViewRepresentable( + viewController: $viewController, + items: [], + cellBuilder: { message in + ChatBubbleView(message: message) + }, + heightCalculator: calculateCellHeight + ) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let initial = generateSampleMessages(count: 20, startId: 0) + viewController?.setItems(initial) + } + } + } + } +} + +#Preview("TiledView Demo") { + BookTiledView() +} diff --git a/Sources/MessagingUI/Tiled/README.md b/Sources/MessagingUI/Tiled/README.md new file mode 100644 index 0000000..5af0edd --- /dev/null +++ b/Sources/MessagingUI/Tiled/README.md @@ -0,0 +1,198 @@ +# TiledView + +双方向スクロール(上下両方向への無限スクロール)を実現するUICollectionViewベースのフレームワーク。チャットUIなど、上方向へのアイテム追加が必要なユースケースに最適。 + +## 特徴 + +- **contentOffset調整なし**: 巨大な仮想コンテンツ領域(100,000,000px)を使用し、prepend時にcontentOffsetを調整する必要がない +- **SwiftUIセル対応**: `UIHostingConfiguration`を使用してSwiftUI Viewをセルとして表示 +- **ジェネリクス対応**: 任意の`Identifiable`アイテムとSwiftUI Viewを使用可能 +- **動的セル高さ**: セルの高さを動的に計算・更新可能 + +## アーキテクチャ + +``` +┌─────────────────────────────────────────────────────────┐ +│ Virtual Content Space │ +│ (100,000,000px) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Anchor Point (50,000,000) │ │ +│ │ ↓ │ │ +│ │ ┌───────────────────────────────────────────┐ │ │ +│ │ │ Prepended Items (negative Y direction) │ │ │ +│ │ ├───────────────────────────────────────────┤ │ │ +│ │ │ Initial Items │ │ │ +│ │ ├───────────────────────────────────────────┤ │ │ +│ │ │ Appended Items (positive Y direction) │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ contentInset: 負の値でバウンス領域を制限 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 仕組み + +1. **巨大な仮想コンテンツ**: `contentSize`を100,000,000pxに設定 +2. **アンカーポイント**: 最初のアイテムを中央(50,000,000px)付近に配置 +3. **Prepend**: アンカーより上(負の方向)にアイテムを追加 +4. **Append**: 最後のアイテムより下(正の方向)にアイテムを追加 +5. **負のcontentInset**: 実際のコンテンツ領域外でバウンスするように調整 + +## ファイル構成 + +``` +TiledView/ +├── TiledCollectionViewLayout.swift # カスタムUICollectionViewLayout +├── TiledView.swift # Cell, ViewController, SwiftUI Representable +├── Demo/ +│ └── TiledViewDemo.swift # デモ用Preview +└── README.md # このファイル +``` + +## クラス説明 + +### TiledCollectionViewLayout + +`UICollectionViewLayout`のサブクラス。アイテムのY座標と高さを管理。 + +```swift +public final class TiledCollectionViewLayout: UICollectionViewLayout { + // アイテム追加 + func appendItems(heights: [CGFloat]) + func prependItems(heights: [CGFloat]) + + // 高さ更新 + func updateItemHeight(at index: Int, newHeight: CGFloat) + + // contentInset計算 + func calculateContentInset() -> UIEdgeInsets +} +``` + +### TiledViewCell + +SwiftUI Viewを表示する`UICollectionViewCell`。 + +```swift +public final class TiledViewCell: UICollectionViewCell { + func configure(with content: Content) +} +``` + +### TiledViewController + +ジェネリクス対応のViewController。 + +```swift +public final class TiledViewController: UIViewController { + // アイテム設定 + func setItems(_ newItems: [Item]) + func prependItems(_ newItems: [Item]) + func appendItems(_ newItems: [Item]) + + // 高さ更新 + func updateItemHeight(at index: Int, newHeight: CGFloat) +} +``` + +### TiledViewRepresentable + +SwiftUI用ラッパー。 + +```swift +public struct TiledViewRepresentable: UIViewControllerRepresentable { + init( + viewController: Binding?>, + items: [Item], + @ViewBuilder cellBuilder: @escaping (Item) -> Cell, + heightCalculator: @escaping (Item, CGFloat) -> CGFloat + ) +} +``` + +## 使用例 + +```swift +import TiledView +import SwiftUI + +struct Message: Identifiable { + let id: Int + let text: String +} + +struct MessageBubble: View { + let message: Message + + var body: some View { + Text(message.text) + .padding() + .background(Color.gray.opacity(0.2)) + .cornerRadius(12) + } +} + +struct ChatView: View { + @State private var viewController: TiledViewController? + + var body: some View { + TiledViewRepresentable( + viewController: $viewController, + items: [], + cellBuilder: { message in + MessageBubble(message: message) + }, + heightCalculator: { message, width in + // セルの高さを計算 + calculateHeight(for: message, width: width) + } + ) + .onAppear { + // 初期データ設定 + viewController?.setItems(initialMessages) + } + } + + func loadMoreOlder() { + viewController?.prependItems(olderMessages) + } + + func loadMoreNewer() { + viewController?.appendItems(newerMessages) + } +} +``` + +## 開発経緯 + +### PoC実装(BookBidirectionalVerticalScrollView.swift) + +2つの方式を比較検討: + +| 方式 | 実装 | 結果 | +|------|------|------| +| A | CATiledLayer + UIView Cell | タイル描画は動作するが、チャットUIには不向き | +| B | UICollectionView + Custom Layout | ✅ 採用 | + +### 方式Bを選択した理由 + +1. **UICollectionViewの再利用**: セルの再利用が自動的に行われる +2. **UIHostingConfiguration**: SwiftUI Viewを簡単に統合可能 +3. **レイアウト制御**: カスタムLayoutで完全なY座標制御が可能 +4. **パフォーマンス**: 可視セルのみ描画される + +## 今後の改善案 + +- [ ] 選択状態のサポート +- [ ] スクロール位置のコールバック +- [ ] 自動ページング(上端/下端到達時のロード) +- [ ] アニメーション付きのアイテム追加/削除 +- [ ] セクションサポート + +## 参考 + +- 元PoC: `Book2025-iOS26/BookBidirectionalVerticalScrollView.swift` +- 方式A(CATiledLayer)は参考実装として上記ファイルに残存 diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift new file mode 100644 index 0000000..b14c01e --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -0,0 +1,122 @@ +// +// TiledCollectionViewLayout.swift +// TiledView +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import UIKit + +// MARK: - TiledCollectionViewLayout + +public final class TiledCollectionViewLayout: UICollectionViewLayout { + + private var itemAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:] + private var itemYPositions: [CGFloat] = [] + private var itemHeights: [CGFloat] = [] + + private let virtualContentHeight: CGFloat = 100_000_000 + private let anchorY: CGFloat = 50_000_000 + + public override var collectionViewContentSize: CGSize { + CGSize( + width: collectionView?.bounds.width ?? 0, + height: virtualContentHeight + ) + } + + public override func prepare() { + guard let collectionView else { return } + itemAttributes.removeAll() + + let itemCount = collectionView.numberOfItems(inSection: 0) + + for index in 0.. [UICollectionViewLayoutAttributes]? { + itemAttributes.values.filter { $0.frame.intersects(rect) } + } + + public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + itemAttributes[indexPath] + } + + public func appendItems(heights: [CGFloat]) { + for height in heights { + let y: CGFloat + if let lastY = itemYPositions.last, let lastHeight = itemHeights.last { + y = lastY + lastHeight + } else { + y = anchorY + } + itemYPositions.append(y) + itemHeights.append(height) + } + } + + public func prependItems(heights: [CGFloat]) { + for height in heights.reversed() { + let y = (itemYPositions.first ?? anchorY) - height + itemYPositions.insert(y, at: 0) + itemHeights.insert(height, at: 0) + } + } + + public func firstItemY() -> CGFloat? { + itemYPositions.first + } + + public func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { + guard let firstY = itemYPositions.first, + let lastY = itemYPositions.last, + let lastHeight = itemHeights.last else { return nil } + return (firstY, lastY + lastHeight) + } + + public func calculateContentInset() -> UIEdgeInsets { + guard let bounds = contentBounds() else { return .zero } + + let topInset = bounds.top + let bottomInset = virtualContentHeight - bounds.bottom + + return UIEdgeInsets( + top: -topInset, + left: 0, + bottom: -bottomInset, + right: 0 + ) + } + + public func clear() { + itemYPositions.removeAll() + itemHeights.removeAll() + itemAttributes.removeAll() + } + + public func updateItemHeight(at index: Int, newHeight: CGFloat) { + guard index >= 0, index < itemHeights.count else { return } + + let oldHeight = itemHeights[index] + let heightDiff = newHeight - oldHeight + + itemHeights[index] = newHeight + + // Update Y positions for all items after this index + for i in (index + 1)..? + + public func configure(with content: Content) { + contentConfiguration = UIHostingConfiguration { + content + } + .margins(.all, 0) + } + + public override func prepareForReuse() { + super.prepareForReuse() + hostingController = nil + } +} + +// MARK: - TiledViewController + +public final class TiledViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate { + + private var collectionView: UICollectionView! + private var tiledLayout: TiledCollectionViewLayout! + + private var items: [Item] = [] + private let cellBuilder: (Item) -> Cell + private let heightCalculator: (Item, CGFloat) -> CGFloat + + public var onPrepend: (() -> Void)? + public var onAppend: (() -> Void)? + + public init( + cellBuilder: @escaping (Item) -> Cell, + heightCalculator: @escaping (Item, CGFloat) -> CGFloat + ) { + self.cellBuilder = cellBuilder + self.heightCalculator = heightCalculator + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + } + + private func setupCollectionView() { + tiledLayout = TiledCollectionViewLayout() + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: tiledLayout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = .systemBackground + collectionView.allowsSelection = true + collectionView.dataSource = self + collectionView.delegate = self + + collectionView.register(TiledViewCell.self, forCellWithReuseIdentifier: TiledViewCell.reuseIdentifier) + + view.addSubview(collectionView) + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func centerOnItems() { + guard let firstY = tiledLayout.firstItemY() else { return } + collectionView.contentOffset = CGPoint(x: 0, y: firstY - 100) + } + + private func updateContentInset() { + collectionView.contentInset = tiledLayout.calculateContentInset() + } + + public func setItems(_ newItems: [Item]) { + let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 + let heights = newItems.map { heightCalculator($0, width) } + tiledLayout.clear() + tiledLayout.appendItems(heights: heights) + items = newItems + collectionView.reloadData() + updateContentInset() + + DispatchQueue.main.async { [weak self] in + self?.centerOnItems() + } + } + + public func prependItems(_ newItems: [Item]) { + let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 + let heights = newItems.map { heightCalculator($0, width) } + tiledLayout.prependItems(heights: heights) + + items.insert(contentsOf: newItems, at: 0) + collectionView.reloadData() + updateContentInset() + } + + public func appendItems(_ newItems: [Item]) { + let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 + let heights = newItems.map { heightCalculator($0, width) } + tiledLayout.appendItems(heights: heights) + + items.append(contentsOf: newItems) + collectionView.reloadData() + updateContentInset() + } + + public func updateItemHeight(at index: Int, newHeight: CGFloat) { + tiledLayout.updateItemHeight(at: index, newHeight: newHeight) + collectionView.reloadData() + updateContentInset() + } + + // MARK: UICollectionViewDataSource + + public func numberOfSections(in collectionView: UICollectionView) -> Int { + 1 + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TiledViewCell.reuseIdentifier, for: indexPath) as! TiledViewCell + let item = items[indexPath.item] + cell.configure(with: cellBuilder(item)) + return cell + } + + // MARK: UICollectionViewDelegate + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // Override in subclass or use closure if needed + } +} + +// MARK: - TiledViewRepresentable + +public struct TiledViewRepresentable: UIViewControllerRepresentable { + + public typealias UIViewControllerType = TiledViewController + + @Binding var viewController: TiledViewController? + let items: [Item] + let cellBuilder: (Item) -> Cell + let heightCalculator: (Item, CGFloat) -> CGFloat + + public init( + viewController: Binding?>, + items: [Item], + @ViewBuilder cellBuilder: @escaping (Item) -> Cell, + heightCalculator: @escaping (Item, CGFloat) -> CGFloat + ) { + self._viewController = viewController + self.items = items + self.cellBuilder = cellBuilder + self.heightCalculator = heightCalculator + } + + public func makeUIViewController(context: Context) -> TiledViewController { + let vc = TiledViewController(cellBuilder: cellBuilder, heightCalculator: heightCalculator) + DispatchQueue.main.async { + viewController = vc + } + return vc + } + + public func updateUIViewController(_ uiViewController: TiledViewController, context: Context) { + // Items are managed externally via viewController reference + } +} From 52132e3cfc60cbbbcb7adee99899d69104d47c27 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 18:33:20 +0900 Subject: [PATCH 02/27] WIP --- .claude/settings.local.json | 7 +++ .../TiledViewDemo.swift | 48 ++++++++++------- .../Tiled/TiledCollectionViewLayout.swift | 8 +++ Sources/MessagingUI/Tiled/TiledView.swift | 52 +++++++------------ 4 files changed, 65 insertions(+), 50 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d178e28 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__XcodeBuildMCP__discover_projs" + ] + } +} diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 70ce7bd..5bd03d3 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -39,20 +39,32 @@ private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { // MARK: - Chat Bubble View private struct ChatBubbleView: View { + + @State var isFolded: Bool = false + let message: ChatMessage var body: some View { - HStack { - Text(message.text) - .font(.system(size: 16)) - .foregroundStyle(.primary) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6)) - ) - - Spacer(minLength: 44) + Group { + if isFolded { + Text("Tap to open") + } else { + HStack { + Text(message.text) + .font(.system(size: 16)) + .foregroundStyle(.primary) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + + Spacer(minLength: 44) + } + } + } + .onTapGesture { + isFolded.toggle() } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -78,7 +90,7 @@ private func calculateCellHeight(for message: ChatMessage, width: CGFloat) -> CG struct BookTiledView: View { - @State private var viewController: TiledViewController? + @State private var tiledView: TiledView? @State private var nextPrependId = -1 @State private var nextAppendId = 20 @@ -86,18 +98,18 @@ struct BookTiledView: View { VStack(spacing: 0) { HStack { Button("Prepend 5") { - guard let viewController else { return } + guard let tiledView else { return } let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) - viewController.prependItems(messages) + tiledView.prependItems(messages) nextPrependId -= 5 } Spacer() Button("Append 5") { - guard let viewController else { return } + guard let tiledView else { return } let messages = generateSampleMessages(count: 5, startId: nextAppendId) - viewController.appendItems(messages) + tiledView.appendItems(messages) nextAppendId += 5 } } @@ -105,7 +117,7 @@ struct BookTiledView: View { .background(Color(.systemBackground)) TiledViewRepresentable( - viewController: $viewController, + tiledView: $tiledView, items: [], cellBuilder: { message in ChatBubbleView(message: message) @@ -115,7 +127,7 @@ struct BookTiledView: View { .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { let initial = generateSampleMessages(count: 20, startId: 0) - viewController?.setItems(initial) + tiledView?.setItems(initial) } } } diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index b14c01e..249b385 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -25,8 +25,16 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { ) } + public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + collectionView?.bounds.size != newBounds.size + } + public override func prepare() { guard let collectionView else { return } + + // contentInsetを自動更新 + collectionView.contentInset = calculateContentInset() + itemAttributes.removeAll() let itemCount = collectionView.numberOfItems(inSection: 0) diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 93ec27c..0fccdd3 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -29,9 +29,9 @@ public final class TiledViewCell: UICollectionViewCell { } } -// MARK: - TiledViewController +// MARK: - TiledView -public final class TiledViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate { +public final class TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { private var collectionView: UICollectionView! private var tiledLayout: TiledCollectionViewLayout! @@ -49,18 +49,14 @@ public final class TiledViewController: UIViewCo ) { self.cellBuilder = cellBuilder self.heightCalculator = heightCalculator - super.init(nibName: nil, bundle: nil) + super.init(frame: .zero) + setupCollectionView() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { - super.viewDidLoad() - setupCollectionView() - } - private func setupCollectionView() { tiledLayout = TiledCollectionViewLayout() @@ -73,13 +69,13 @@ public final class TiledViewController: UIViewCo collectionView.register(TiledViewCell.self, forCellWithReuseIdentifier: TiledViewCell.reuseIdentifier) - view.addSubview(collectionView) + addSubview(collectionView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } @@ -88,10 +84,6 @@ public final class TiledViewController: UIViewCo collectionView.contentOffset = CGPoint(x: 0, y: firstY - 100) } - private func updateContentInset() { - collectionView.contentInset = tiledLayout.calculateContentInset() - } - public func setItems(_ newItems: [Item]) { let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 let heights = newItems.map { heightCalculator($0, width) } @@ -99,7 +91,6 @@ public final class TiledViewController: UIViewCo tiledLayout.appendItems(heights: heights) items = newItems collectionView.reloadData() - updateContentInset() DispatchQueue.main.async { [weak self] in self?.centerOnItems() @@ -113,7 +104,6 @@ public final class TiledViewController: UIViewCo items.insert(contentsOf: newItems, at: 0) collectionView.reloadData() - updateContentInset() } public func appendItems(_ newItems: [Item]) { @@ -123,13 +113,11 @@ public final class TiledViewController: UIViewCo items.append(contentsOf: newItems) collectionView.reloadData() - updateContentInset() } public func updateItemHeight(at index: Int, newHeight: CGFloat) { tiledLayout.updateItemHeight(at: index, newHeight: newHeight) collectionView.reloadData() - updateContentInset() } // MARK: UICollectionViewDataSource @@ -158,36 +146,36 @@ public final class TiledViewController: UIViewCo // MARK: - TiledViewRepresentable -public struct TiledViewRepresentable: UIViewControllerRepresentable { +public struct TiledViewRepresentable: UIViewRepresentable { - public typealias UIViewControllerType = TiledViewController + public typealias UIViewType = TiledView - @Binding var viewController: TiledViewController? + @Binding var tiledView: TiledView? let items: [Item] let cellBuilder: (Item) -> Cell let heightCalculator: (Item, CGFloat) -> CGFloat public init( - viewController: Binding?>, + tiledView: Binding?>, items: [Item], @ViewBuilder cellBuilder: @escaping (Item) -> Cell, heightCalculator: @escaping (Item, CGFloat) -> CGFloat ) { - self._viewController = viewController + self._tiledView = tiledView self.items = items self.cellBuilder = cellBuilder self.heightCalculator = heightCalculator } - public func makeUIViewController(context: Context) -> TiledViewController { - let vc = TiledViewController(cellBuilder: cellBuilder, heightCalculator: heightCalculator) + public func makeUIView(context: Context) -> TiledView { + let view = TiledView(cellBuilder: cellBuilder, heightCalculator: heightCalculator) DispatchQueue.main.async { - viewController = vc + tiledView = view } - return vc + return view } - public func updateUIViewController(_ uiViewController: TiledViewController, context: Context) { - // Items are managed externally via viewController reference + public func updateUIView(_ uiView: TiledView, context: Context) { + // Items are managed externally via tiledView reference } } From 09126b36ae3d7679d28ad3aeb231d9041d486f79 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 18:48:34 +0900 Subject: [PATCH 03/27] Update --- .../TiledViewDemo.swift | 3 +- .../Tiled/Demo/TiledViewDemo.swift | 126 ------------------ .../Tiled/TiledCollectionViewLayout.swift | 44 +++++- Sources/MessagingUI/Tiled/TiledView.swift | 56 ++++---- 4 files changed, 66 insertions(+), 163 deletions(-) delete mode 100644 Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 5bd03d3..a742629 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -121,8 +121,7 @@ struct BookTiledView: View { items: [], cellBuilder: { message in ChatBubbleView(message: message) - }, - heightCalculator: calculateCellHeight + } ) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { diff --git a/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift b/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift deleted file mode 100644 index 472bef7..0000000 --- a/Sources/MessagingUI/Tiled/Demo/TiledViewDemo.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// TiledViewDemo.swift -// TiledView -// -// Created by Hiroshi Kimura on 2025/12/10. -// - -import SwiftUI - -// MARK: - Sample Data - -struct ChatMessage: Identifiable, Hashable, Sendable { - let id: Int - let text: String -} - -private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { - let sampleTexts = [ - "こんにちは!", - "今日はいい天気ですね。散歩に行きませんか?", - "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", - "了解です👍", - "ちょっと待ってください。確認してから返信しますね。", - "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", - "OK", - "今から出発します!", - "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", - "🎉🎊✨", - ] - - return (0.. CGFloat { - let padding: CGFloat = 12 - let maxBubbleWidth = width - 60 - - let label = UILabel() - label.numberOfLines = 0 - label.font = .systemFont(ofSize: 16) - label.text = message.text - - let labelSize = label.sizeThatFits(CGSize(width: maxBubbleWidth - padding * 2, height: .greatestFiniteMagnitude)) - return labelSize.height + padding * 2 + 16 -} - -// MARK: - Demo View - -struct BookTiledView: View { - - @State private var viewController: TiledViewController? - @State private var nextPrependId = -1 - @State private var nextAppendId = 20 - - var body: some View { - VStack(spacing: 0) { - HStack { - Button("Prepend 5") { - guard let viewController else { return } - let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) - viewController.prependItems(messages) - nextPrependId -= 5 - } - - Spacer() - - Button("Append 5") { - guard let viewController else { return } - let messages = generateSampleMessages(count: 5, startId: nextAppendId) - viewController.appendItems(messages) - nextAppendId += 5 - } - } - .padding() - .background(Color(.systemBackground)) - - TiledViewRepresentable( - viewController: $viewController, - items: [], - cellBuilder: { message in - ChatBubbleView(message: message) - }, - heightCalculator: calculateCellHeight - ) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let initial = generateSampleMessages(count: 20, startId: 0) - viewController?.setItems(initial) - } - } - } - } -} - -#Preview("TiledView Demo") { - BookTiledView() -} diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 249b385..6efc7b1 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -62,8 +62,38 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { itemAttributes[indexPath] } - public func appendItems(heights: [CGFloat]) { - for height in heights { + // MARK: - Self-Sizing Support + + public override func shouldInvalidateLayout( + forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, + withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes + ) -> Bool { + preferredAttributes.frame.size.height != originalAttributes.frame.size.height + } + + public override func invalidationContext( + forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, + withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutInvalidationContext { + let context = super.invalidationContext( + forPreferredLayoutAttributes: preferredAttributes, + withOriginalAttributes: originalAttributes + ) + + let index = preferredAttributes.indexPath.item + let newHeight = preferredAttributes.frame.size.height + + if index < itemHeights.count { + updateItemHeight(at: index, newHeight: newHeight) + } + + return context + } + + private let estimatedHeight: CGFloat = 44 + + public func appendItems(count: Int) { + for _ in 0..? - public func configure(with content: Content) { contentConfiguration = UIHostingConfiguration { content @@ -25,7 +23,28 @@ public final class TiledViewCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() - hostingController = nil + contentConfiguration = nil + } + + public override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes + + // UIHostingConfigurationを使用している場合、systemLayoutSizeFittingでサイズを取得 + let targetSize = CGSize( + width: layoutAttributes.frame.width, + height: UIView.layoutFittingCompressedSize.height + ) + + let size = contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + attributes.frame.size.height = size.height + return attributes } } @@ -38,17 +57,14 @@ public final class TiledView: UIView, UICollecti private var items: [Item] = [] private let cellBuilder: (Item) -> Cell - private let heightCalculator: (Item, CGFloat) -> CGFloat public var onPrepend: (() -> Void)? public var onAppend: (() -> Void)? public init( - cellBuilder: @escaping (Item) -> Cell, - heightCalculator: @escaping (Item, CGFloat) -> CGFloat + cellBuilder: @escaping (Item) -> Cell ) { self.cellBuilder = cellBuilder - self.heightCalculator = heightCalculator super.init(frame: .zero) setupCollectionView() } @@ -85,10 +101,8 @@ public final class TiledView: UIView, UICollecti } public func setItems(_ newItems: [Item]) { - let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 - let heights = newItems.map { heightCalculator($0, width) } tiledLayout.clear() - tiledLayout.appendItems(heights: heights) + tiledLayout.appendItems(count: newItems.count) items = newItems collectionView.reloadData() @@ -98,28 +112,17 @@ public final class TiledView: UIView, UICollecti } public func prependItems(_ newItems: [Item]) { - let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 - let heights = newItems.map { heightCalculator($0, width) } - tiledLayout.prependItems(heights: heights) - + tiledLayout.prependItems(count: newItems.count) items.insert(contentsOf: newItems, at: 0) collectionView.reloadData() } public func appendItems(_ newItems: [Item]) { - let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : 375 - let heights = newItems.map { heightCalculator($0, width) } - tiledLayout.appendItems(heights: heights) - + tiledLayout.appendItems(count: newItems.count) items.append(contentsOf: newItems) collectionView.reloadData() } - public func updateItemHeight(at index: Int, newHeight: CGFloat) { - tiledLayout.updateItemHeight(at: index, newHeight: newHeight) - collectionView.reloadData() - } - // MARK: UICollectionViewDataSource public func numberOfSections(in collectionView: UICollectionView) -> Int { @@ -153,22 +156,19 @@ public struct TiledViewRepresentable: UIViewRepr @Binding var tiledView: TiledView? let items: [Item] let cellBuilder: (Item) -> Cell - let heightCalculator: (Item, CGFloat) -> CGFloat public init( tiledView: Binding?>, items: [Item], - @ViewBuilder cellBuilder: @escaping (Item) -> Cell, - heightCalculator: @escaping (Item, CGFloat) -> CGFloat + @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self._tiledView = tiledView self.items = items self.cellBuilder = cellBuilder - self.heightCalculator = heightCalculator } public func makeUIView(context: Context) -> TiledView { - let view = TiledView(cellBuilder: cellBuilder, heightCalculator: heightCalculator) + let view = TiledView(cellBuilder: cellBuilder) DispatchQueue.main.async { tiledView = view } From cff5bf53b6865d4c44222770c057d2ee1206104d Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 19:10:49 +0900 Subject: [PATCH 04/27] Refactor TiledViewController to UIView and add attributes cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change TiledViewController (UIViewController) to TiledView (UIView) - Simplifies integration as subview without child VC management - UICollectionView wrapper doesn't need full ViewController overhead - Update TiledViewRepresentable to UIViewRepresentable - Rename viewController binding to tiledView - Add layout attributes caching to TiledCollectionViewLayout - New usesAttributesCache option (default: true) - Reuse UICollectionViewLayoutAttributes objects instead of recreating - Only rebuild on bounds size change or explicit clear() - Improves scroll performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TiledViewDemo.swift | 19 +--- .../Tiled/TiledCollectionViewLayout.swift | 87 +++++++++++++++---- 2 files changed, 72 insertions(+), 34 deletions(-) diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index a742629..6719872 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -64,28 +64,15 @@ private struct ChatBubbleView: View { } } .onTapGesture { - isFolded.toggle() + withAnimation(.bouncy) { + isFolded.toggle() + } } .padding(.horizontal, 16) .padding(.vertical, 8) } } -// MARK: - Height Calculator - -private func calculateCellHeight(for message: ChatMessage, width: CGFloat) -> CGFloat { - let padding: CGFloat = 12 - let maxBubbleWidth = width - 60 - - let label = UILabel() - label.numberOfLines = 0 - label.font = .systemFont(ofSize: 16) - label.text = message.text - - let labelSize = label.sizeThatFits(CGSize(width: maxBubbleWidth - padding * 2, height: .greatestFiniteMagnitude)) - return labelSize.height + padding * 2 + 16 -} - // MARK: - Demo View struct BookTiledView: View { diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 6efc7b1..f162d50 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -11,9 +11,19 @@ import UIKit public final class TiledCollectionViewLayout: UICollectionViewLayout { - private var itemAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:] + // MARK: - Configuration + + /// Enables caching of layout attributes for better scroll performance. + /// When enabled, attributes are reused instead of being recreated on each prepare() call. + public var usesAttributesCache: Bool = true + + // MARK: - Private Properties + + private var attributesCache: [IndexPath: UICollectionViewLayoutAttributes] = [:] private var itemYPositions: [CGFloat] = [] private var itemHeights: [CGFloat] = [] + private var lastPreparedBoundsSize: CGSize = .zero + private var needsFullAttributesRebuild: Bool = true private let virtualContentHeight: CGFloat = 100_000_000 private let anchorY: CGFloat = 50_000_000 @@ -35,31 +45,71 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { // contentInsetを自動更新 collectionView.contentInset = calculateContentInset() - itemAttributes.removeAll() - + let boundsSize = collectionView.bounds.size let itemCount = collectionView.numberOfItems(inSection: 0) - for index in 0.. itemCount { + attributesCache = attributesCache.filter { $0.key.item < itemCount } + } } } public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - itemAttributes.values.filter { $0.frame.intersects(rect) } + attributesCache.values.filter { $0.frame.intersects(rect) } } public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - itemAttributes[indexPath] + attributesCache[indexPath] } // MARK: - Self-Sizing Support @@ -141,7 +191,8 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { public func clear() { itemYPositions.removeAll() itemHeights.removeAll() - itemAttributes.removeAll() + attributesCache.removeAll() + needsFullAttributesRebuild = true } public func updateItemHeight(at index: Int, newHeight: CGFloat) { From 26eca830819c2dd9a0c8f910a2e8a5040ffc6be3 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 21:03:18 +0900 Subject: [PATCH 05/27] Update --- .../Tiled/TiledCollectionViewLayout.swift | 33 +- Sources/MessagingUI/Tiled/TiledView.swift | 30 +- docs/TiledView-Architecture.md | 375 ++++++++++++++++++ 3 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 docs/TiledView-Architecture.md diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index f162d50..03607f8 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -17,6 +17,10 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { /// When enabled, attributes are reused instead of being recreated on each prepare() call. public var usesAttributesCache: Bool = true + /// サイズを問い合わせるclosure。indexとwidthを渡し、サイズを返す。 + /// nilを返した場合はestimatedHeightを使用。 + public var itemSizeProvider: ((_ index: Int, _ width: CGFloat) -> CGSize?)? + // MARK: - Private Properties private var attributesCache: [IndexPath: UICollectionViewLayoutAttributes] = [:] @@ -35,9 +39,9 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { ) } - public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - collectionView?.bounds.size != newBounds.size - } +// public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { +// collectionView?.bounds.size != newBounds.size +// } public override func prepare() { guard let collectionView else { return } @@ -140,10 +144,15 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { return context } - private let estimatedHeight: CGFloat = 44 + private let estimatedHeight: CGFloat = 100 + + public func appendItems(count: Int, startingIndex: Int) { + let width = collectionView?.bounds.width ?? 0 + + for i in 0..: UIView, UICollecti private var items: [Item] = [] private let cellBuilder: (Item) -> Cell + /// サイズ計測用のHostingController(再利用) + private lazy var sizingHostingController = UIHostingController(rootView: nil) + public var onPrepend: (() -> Void)? public var onAppend: (() -> Void)? @@ -75,9 +78,13 @@ public final class TiledView: UIView, UICollecti private func setupCollectionView() { tiledLayout = TiledCollectionViewLayout() + tiledLayout.itemSizeProvider = { [weak self] index, width in + self?.measureSize(at: index, width: width) + } collectionView = UICollectionView(frame: .zero, collectionViewLayout: tiledLayout) collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.selfSizingInvalidation = .enabledIncludingConstraints collectionView.backgroundColor = .systemBackground collectionView.allowsSelection = true collectionView.dataSource = self @@ -95,6 +102,19 @@ public final class TiledView: UIView, UICollecti ]) } + private func measureSize(at index: Int, width: CGFloat) -> CGSize? { + guard index < items.count else { return nil } + let item = items[index] + sizingHostingController.rootView = cellBuilder(item) + sizingHostingController.sizingOptions = .preferredContentSize + sizingHostingController.view.layoutIfNeeded() + + let size = sizingHostingController.sizeThatFits( + in: .init(width: width, height: UIView.layoutFittingCompressedSize.height) + ) + return size + } + private func centerOnItems() { guard let firstY = tiledLayout.firstItemY() else { return } collectionView.contentOffset = CGPoint(x: 0, y: firstY - 100) @@ -102,8 +122,8 @@ public final class TiledView: UIView, UICollecti public func setItems(_ newItems: [Item]) { tiledLayout.clear() - tiledLayout.appendItems(count: newItems.count) items = newItems + tiledLayout.appendItems(count: newItems.count, startingIndex: 0) collectionView.reloadData() DispatchQueue.main.async { [weak self] in @@ -112,15 +132,19 @@ public final class TiledView: UIView, UICollecti } public func prependItems(_ newItems: [Item]) { - tiledLayout.prependItems(count: newItems.count) items.insert(contentsOf: newItems, at: 0) + tiledLayout.prependItems(count: newItems.count) collectionView.reloadData() + collectionView.invalidateIntrinsicContentSize() + collectionView.layoutIfNeeded() } public func appendItems(_ newItems: [Item]) { - tiledLayout.appendItems(count: newItems.count) + let startingIndex = items.count items.append(contentsOf: newItems) + tiledLayout.appendItems(count: newItems.count, startingIndex: startingIndex) collectionView.reloadData() + collectionView.layoutIfNeeded() } // MARK: UICollectionViewDataSource diff --git a/docs/TiledView-Architecture.md b/docs/TiledView-Architecture.md new file mode 100644 index 0000000..01b1cd8 --- /dev/null +++ b/docs/TiledView-Architecture.md @@ -0,0 +1,375 @@ +# TiledView Architecture Document + +## Overview + +TiledViewは、双方向無限スクロールを実現するためのUICollectionViewベースのコンポーネントです。 +チャットUIのように、上方向(過去のメッセージ)と下方向(新しいメッセージ)の両方にコンテンツを追加できます。 + +## Core Concept: Virtual Content Space + +### 基本設計 + +``` +┌─────────────────────────────────────┐ +│ │ +│ Virtual Content Space │ +│ (100,000,000 pixels) │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ │ │ +│ │ Prepend Area │ │ +│ │ (items added to top) │ │ +│ │ │ │ +│ ├─────────────────────────────┤ │ ← anchorY (50,000,000) +│ │ │ │ +│ │ Initial Items │ │ +│ │ │ │ +│ ├─────────────────────────────┤ │ +│ │ │ │ +│ │ Append Area │ │ +│ │ (items added to bottom) │ │ +│ │ │ │ +│ └─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### なぜこの設計か + +通常のUICollectionViewでは、先頭にアイテムを追加すると`contentOffset`がずれてしまい、 +ユーザーの見ている位置がジャンプしてしまいます。 + +従来の解決策は、prepend後に`contentOffset`を調整することですが、 +これには以下の問題があります: + +1. 視覚的なジャンプやちらつきが発生する可能性 +2. アニメーション中の調整が困難 +3. タイミングによっては競合状態が発生 + +**Virtual Content Space設計**では、`contentOffset`を一切変更しません。 +代わりに、巨大な仮想空間(1億ピクセル)の中央(5千万ピクセル)をアンカーポイントとして、 +アイテムのY位置自体を調整します。 + +### Prepend時の動作 + +``` +Before: After: +┌──────────┐ ┌──────────┐ +│ Item 0 │ y=50000000 │ New Item │ y=49999900 (= 50000000 - 100) +├──────────┤ ├──────────┤ +│ Item 1 │ y=50000100 │ Item 0 │ y=50000000 (unchanged) +├──────────┤ ├──────────┤ +│ Item 2 │ y=50000200 │ Item 1 │ y=50000100 (unchanged) +└──────────┘ ├──────────┤ + │ Item 2 │ y=50000200 (unchanged) + └──────────┘ + +contentOffset: unchanged (user's view position stays the same) +``` + +新しいアイテムは既存アイテムの**上**に配置され、既存アイテムのY位置は変わりません。 +`contentOffset`も変わらないため、ユーザーの見ている位置は完全に維持されます。 + +### Append時の動作 + +``` +Before: After: +┌──────────┐ ┌──────────┐ +│ Item 0 │ y=50000000 │ Item 0 │ y=50000000 (unchanged) +├──────────┤ ├──────────┤ +│ Item 1 │ y=50000100 │ Item 1 │ y=50000100 (unchanged) +├──────────┤ ├──────────┤ +│ Item 2 │ y=50000200 │ Item 2 │ y=50000200 (unchanged) +└──────────┘ ├──────────┤ + │ New Item │ y=50000300 (= 50000200 + 100) + └──────────┘ +``` + +--- + +## Current Implementation (Imperative API) + +### 構成要素 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TiledViewRepresentable (SwiftUI Bridge) │ +│ - @Binding var tiledView: TiledView? │ +│ - Exposes TiledView reference for imperative operations │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ TiledView (UIView) │ +│ - items: [Item] │ +│ - cellBuilder: (Item) -> Cell │ +│ - sizingHostingController: UIHostingController │ +│ │ +│ Public Methods: │ +│ - setItems(_ newItems: [Item]) │ +│ - prependItems(_ newItems: [Item]) │ +│ - appendItems(_ newItems: [Item]) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ TiledCollectionViewLayout (UICollectionViewLayout) │ +│ - itemYPositions: [CGFloat] │ +│ - itemHeights: [CGFloat] │ +│ - itemSizeProvider: ((Int, CGFloat) -> CGSize?)? │ +│ │ +│ Methods: │ +│ - appendItems(count:startingIndex:) │ +│ - prependItems(count:) │ +│ - updateItemHeight(at:newHeight:) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 使用例 + +```swift +struct ChatView: View { + @State private var tiledView: TiledView? + + var body: some View { + VStack { + Button("Load Older") { + let olderMessages = fetchOlderMessages() + tiledView?.prependItems(olderMessages) // Imperative call + } + + TiledViewRepresentable( + tiledView: $tiledView, + items: [], + cellBuilder: { ChatBubbleView(message: $0) } + ) + .onAppear { + let initialMessages = fetchInitialMessages() + tiledView?.setItems(initialMessages) // Imperative call + } + } + } +} +``` + +### itemSizeProvider による事前サイズ計測 + +セルが表示される前に正確なサイズを取得するため、`itemSizeProvider`クロージャを使用します。 + +```swift +// TiledView内での設定 +tiledLayout.itemSizeProvider = { [weak self] index, width in + self?.measureSize(at: index, width: width) +} + +// サイズ計測(UIHostingControllerを再利用) +private func measureSize(at index: Int, width: CGFloat) -> CGSize? { + guard index < items.count else { return nil } + let item = items[index] + sizingHostingController.rootView = cellBuilder(item) + sizingHostingController.view.layoutIfNeeded() + + return sizingHostingController.view.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) +} +``` + +**重要**: `UIHostingController`のインスタンスを毎回生成するのはコストが高いため、 +1つのインスタンスを保持して`rootView`を差し替えることで再利用しています。 + +--- + +## Challenges: Declarative API + +### 目標 + +```swift +// 理想的な宣言的API +struct ChatView: View { + @State private var messages: [ChatMessage] = [] + + var body: some View { + TiledViewRepresentable( + items: messages, // State binding only + cellBuilder: { ChatBubbleView(message: $0) } + ) + } + + func loadOlder() { + messages.insert(contentsOf: olderMessages, at: 0) // Just modify state + } + + func loadNewer() { + messages.append(contentsOf: newerMessages) // Just modify state + } +} +``` + +### 根本的な問題 + +SwiftUIの宣言的APIでは「現在の状態」しか渡されません。 +しかし、TiledCollectionViewLayoutは「prepend vs append」を知る必要があります。 + +``` +SwiftUI World: Layout World: +───────────────── ───────────────── +"Here are the items" "Where did the new items go?" +[A, B, C, D, E] - Prepend? → Y positions shift up + - Append? → Y positions extend down + - Insert in middle? → ??? +``` + +### 試みたアプローチと失敗理由 + +#### 1. DiffableDataSource + prepare()自動検出 + +**アプローチ:** +- `UICollectionViewDiffableDataSource`でデータ管理 +- `prepare()`内で`numberOfItems(inSection:)`から自動的にアイテム数を検出 +- 配列サイズが増えたら自動的に拡張 + +**失敗理由:** +- `prepare()`での自動拡張と明示的な`prependItems()`呼び出しが競合 +- アイテムが重複して追加される +- タイミングの制御が困難 + +```swift +// prepare()内 +while itemHeights.count < itemCount { + // ここで追加されるが... +} + +// 外部から +tiledView.prependItems(newItems) // ここでも追加 → 重複 +``` + +#### 2. Prepend検出 + prependItems呼び出し + +**アプローチ:** +- 新旧アイテムを比較してprepend数を検出 +- 検出結果に基づいて`prependItems()`を呼び出し + +**失敗理由:** +- 検出タイミングと`prepare()`のタイミングが競合 +- 自動拡張との整合性が取れない + +#### 3. Anchor-based contentOffset調整 + +**アプローチ:** +- 更新前に可視アイテムのスクリーン位置を記録 +- スナップショット適用後、アンカーアイテムが同じスクリーン位置に来るように`contentOffset`を調整 + +**失敗理由:** +- **却下** - Virtual Content Spaceのコンセプトに反する +- `contentOffset`を変更すると、このアーキテクチャを採用する意味がなくなる +- 「それをするなら最初から巨大なcontentSizeをとっておく必要がない」 + +#### 4. 手動diff + batch updates + +**アプローチ:** +- `Collection.difference(from:)`でdiffを計算 +- `UICollectionView.performBatchUpdates`で手動でinsert/delete +- Layoutに`applyDiff`メソッドを追加 + +**失敗理由:** +- UIが完全に崩壊 +- diffの適用順序とLayoutの状態管理の整合性が取れない +- 複雑すぎて保守困難 + +--- + +## Current Constraints + +### 絶対的な制約 + +1. **contentOffsetは変更しない** + - Virtual Content Spaceアーキテクチャの根幹 + - これを変更するとアーキテクチャ全体を再設計する必要がある + +2. **Layoutはprepend/appendを明示的に知る必要がある** + - Y位置の計算方向が異なる + - Prepend: 既存アイテムの上に配置(Y位置が減少) + - Append: 既存アイテムの下に配置(Y位置が増加) + +### 実装上の制約 + +1. **items配列とLayout配列の同期** + - `TiledView.items`と`TiledCollectionViewLayout.itemYPositions/itemHeights`は常に同期している必要がある + - 順序: items配列を先に更新 → Layoutを更新 + +2. **サイズ計測のタイミング** + - `itemSizeProvider`はitems配列が更新された後に呼ばれる + - `measureSize`はitems配列にアクセスするため、順序が重要 + +--- + +## Future Considerations + +### 宣言的APIを実現するための可能なアプローチ + +#### Option A: ID-based Positioning + +アイテムのIDに基づいてprepend/appendを判断する。 + +```swift +// Item.IDがComparableの場合 +if newItems.first?.id < existingItems.first?.id { + // Prepend +} else if newItems.last?.id > existingItems.last?.id { + // Append +} +``` + +**制約:** +- `Item.ID: Comparable`が必要 +- IDが順序を表す前提(連番など) + +#### Option B: Anchor-based Layout (Different Architecture) + +完全に異なるアーキテクチャで、アンカーアイテムを基準にレイアウトを構築。 + +**検討事項:** +- Virtual Content Spaceを維持しつつ実現可能か +- パフォーマンスへの影響 + +#### Option C: Hybrid API + +宣言的な部分と命令的な部分を組み合わせる。 + +```swift +TiledViewRepresentable( + items: messages, + changeHint: .prepend(count: 5), // 変更のヒントを提供 + cellBuilder: { ... } +) +``` + +**課題:** +- SwiftUIの`updateUIView`で前回の状態を保持する必要 +- Coordinatorパターンの活用 + +--- + +## File Structure + +``` +Sources/MessagingUI/Tiled/ +├── TiledView.swift +│ ├── TiledViewCell - UICollectionViewCell with UIHostingConfiguration +│ ├── TiledView - Main UIView component +│ └── TiledViewRepresentable - SwiftUI bridge +│ +└── TiledCollectionViewLayout.swift + └── TiledCollectionViewLayout - Custom layout with virtual content space +``` + +--- + +## References + +- [UICollectionViewDiffableDataSource](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource) +- [UIHostingConfiguration](https://developer.apple.com/documentation/swiftui/uihostingconfiguration) +- [Collection.difference(from:)](https://developer.apple.com/documentation/swift/collection/difference(from:)) From 73d19357a19ca3ed25bfbe16fb9b6aa6b7413d95 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 10 Dec 2025 22:36:08 +0900 Subject: [PATCH 06/27] Add itemSizeProvider for accurate cell size measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add itemSizeProvider closure to TiledCollectionViewLayout for querying cell sizes - Use TiledViewCell with UIHostingConfiguration for consistent size measurement - Measure sizes using the same method as preferredLayoutAttributesFitting - Add architecture documentation in docs/TiledView-Architecture.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Sources/MessagingUI/Tiled/TiledView.swift | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index fccf57a..d83e6b7 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -58,8 +58,8 @@ public final class TiledView: UIView, UICollecti private var items: [Item] = [] private let cellBuilder: (Item) -> Cell - /// サイズ計測用のHostingController(再利用) - private lazy var sizingHostingController = UIHostingController(rootView: nil) + /// サイズ計測用のCell(再利用) + private lazy var sizingCell = TiledViewCell() public var onPrepend: (() -> Void)? public var onAppend: (() -> Void)? @@ -105,12 +105,20 @@ public final class TiledView: UIView, UICollecti private func measureSize(at index: Int, width: CGFloat) -> CGSize? { guard index < items.count else { return nil } let item = items[index] - sizingHostingController.rootView = cellBuilder(item) - sizingHostingController.sizingOptions = .preferredContentSize - sizingHostingController.view.layoutIfNeeded() - let size = sizingHostingController.sizeThatFits( - in: .init(width: width, height: UIView.layoutFittingCompressedSize.height) + // UIHostingConfigurationと同じ方法で計測 + sizingCell.configure(with: cellBuilder(item)) + sizingCell.layoutIfNeeded() + + let targetSize = CGSize( + width: width, + height: UIView.layoutFittingCompressedSize.height + ) + + let size = sizingCell.contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel ) return size } From 2c6f6dae1453ed4b675339265f3e6166763f93a8 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 00:36:58 +0900 Subject: [PATCH 07/27] Add TiledDataSource for declarative API support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TiledDataSource struct with change tracking (prepend, append, update, remove, setItems) - Use id: UUID and changeCounter: Int for cursor-based change consumption - Update TiledView with applyDataSource method to consume pending changes - Simplify TiledViewRepresentable to accept dataSource instead of @Binding tiledView - Update Demo to use declarative style with @State dataSource 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TiledViewDemo.swift | 24 ++- .../MessagingUI/Tiled/TiledDataSource.swift | 139 ++++++++++++++++++ Sources/MessagingUI/Tiled/TiledView.swift | 82 +++++++++-- 3 files changed, 219 insertions(+), 26 deletions(-) create mode 100644 Sources/MessagingUI/Tiled/TiledDataSource.swift diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 6719872..578ccc9 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -10,7 +10,7 @@ import MessagingUI // MARK: - Sample Data -struct ChatMessage: Identifiable, Hashable, Sendable { +struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { let id: Int let text: String } @@ -77,26 +77,29 @@ private struct ChatBubbleView: View { struct BookTiledView: View { - @State private var tiledView: TiledView? + @State private var dataSource: TiledDataSource @State private var nextPrependId = -1 @State private var nextAppendId = 20 + init() { + let initial = generateSampleMessages(count: 20, startId: 0) + _dataSource = State(initialValue: TiledDataSource(items: initial)) + } + var body: some View { VStack(spacing: 0) { HStack { Button("Prepend 5") { - guard let tiledView else { return } let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) - tiledView.prependItems(messages) + dataSource.prepend(messages) nextPrependId -= 5 } Spacer() Button("Append 5") { - guard let tiledView else { return } let messages = generateSampleMessages(count: 5, startId: nextAppendId) - tiledView.appendItems(messages) + dataSource.append(messages) nextAppendId += 5 } } @@ -104,18 +107,11 @@ struct BookTiledView: View { .background(Color(.systemBackground)) TiledViewRepresentable( - tiledView: $tiledView, - items: [], + dataSource: dataSource, cellBuilder: { message in ChatBubbleView(message: message) } ) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let initial = generateSampleMessages(count: 20, startId: 0) - tiledView?.setItems(initial) - } - } } } } diff --git a/Sources/MessagingUI/Tiled/TiledDataSource.swift b/Sources/MessagingUI/Tiled/TiledDataSource.swift new file mode 100644 index 0000000..a01a4b3 --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledDataSource.swift @@ -0,0 +1,139 @@ +// +// TiledDataSource.swift +// MessagingUI +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import Foundation + +// MARK: - TiledDataSource + +/// A data source for TiledView that tracks changes for efficient updates. +/// +/// Instead of directly modifying an array, use this data source's methods +/// to modify items. This allows TiledView to know exactly what changed +/// and update the layout accordingly without adjusting content offset. +public struct TiledDataSource: Equatable { + + // MARK: - Change + + public enum Change: Equatable { + case setItems([Item]) + case prepend([Item]) + case append([Item]) + case update([Item]) + case remove([Item.ID]) + } + + // MARK: - Properties + + /// Unique identifier for this data source instance. + /// Used by TiledView to detect when the data source is replaced. + public let id: UUID = UUID() + + /// Change counter used as cursor for tracking applied changes. + public private(set) var changeCounter: Int = 0 + + /// The current items in the data source. + public private(set) var items: [Item] = [] + + /// Pending changes that haven't been consumed by TiledView yet. + internal private(set) var pendingChanges: [Change] = [] + + // MARK: - Initializers + + public init() {} + + public init(items: [Item]) { + self.items = items + self.pendingChanges = [.setItems(items)] + self.changeCounter = 1 + } + + // MARK: - Mutation Methods + + /// Sets all items, replacing any existing items. + /// Use this for initial load or complete refresh. + public mutating func setItems(_ items: [Item]) { + self.items = items + pendingChanges.append(.setItems(items)) + changeCounter += 1 + } + + /// Adds items to the beginning of the list. + /// Use this for loading older content (e.g., older messages). + public mutating func prepend(_ items: [Item]) { + guard !items.isEmpty else { return } + self.items.insert(contentsOf: items, at: 0) + pendingChanges.append(.prepend(items)) + changeCounter += 1 + } + + /// Adds items to the end of the list. + /// Use this for loading newer content (e.g., new messages). + public mutating func append(_ items: [Item]) { + guard !items.isEmpty else { return } + self.items.append(contentsOf: items) + pendingChanges.append(.append(items)) + changeCounter += 1 + } + + /// Updates existing items by matching their IDs. + /// Items that don't exist in the current list are ignored. + public mutating func update(_ items: [Item]) { + guard !items.isEmpty else { return } + var updatedItems: [Item] = [] + for item in items { + if let index = self.items.firstIndex(where: { $0.id == item.id }) { + self.items[index] = item + updatedItems.append(item) + } + } + if !updatedItems.isEmpty { + pendingChanges.append(.update(updatedItems)) + changeCounter += 1 + } + } + + /// Removes items with the specified IDs. + public mutating func remove(ids: [Item.ID]) { + guard !ids.isEmpty else { return } + let idsSet = Set(ids) + let removedIds = items.filter { idsSet.contains($0.id) }.map { $0.id } + self.items.removeAll { idsSet.contains($0.id) } + if !removedIds.isEmpty { + pendingChanges.append(.remove(removedIds)) + changeCounter += 1 + } + } + + /// Removes a single item with the specified ID. + public mutating func remove(id: Item.ID) { + remove(ids: [id]) + } + + // MARK: - Equatable + + public static func == (lhs: TiledDataSource, rhs: TiledDataSource) -> Bool { + // Compare id and items, not pendingChanges or changeCounter + // Different id means different data source instance + lhs.id == rhs.id && lhs.items == rhs.items + } +} + +// MARK: - Item.ID Hashable conformance for Set operations + +extension TiledDataSource where Item.ID: Hashable { + + /// Removes items with the specified IDs (optimized for Hashable IDs). + public mutating func remove(ids: Set) { + guard !ids.isEmpty else { return } + let removedIds = items.filter { ids.contains($0.id) }.map { $0.id } + self.items.removeAll { ids.contains($0.id) } + if !removedIds.isEmpty { + pendingChanges.append(.remove(removedIds)) + changeCounter += 1 + } + } +} diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index d83e6b7..c2f93dc 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -50,7 +50,7 @@ public final class TiledViewCell: UICollectionViewCell { // MARK: - TiledView -public final class TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { +public final class TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { private var collectionView: UICollectionView! private var tiledLayout: TiledCollectionViewLayout! @@ -61,6 +61,10 @@ public final class TiledView: UIView, UICollecti /// サイズ計測用のCell(再利用) private lazy var sizingCell = TiledViewCell() + /// DataSource tracking + private var lastDataSourceID: UUID? + private var appliedCursor: Int = 0 + public var onPrepend: (() -> Void)? public var onAppend: (() -> Void)? @@ -155,6 +159,65 @@ public final class TiledView: UIView, UICollecti collectionView.layoutIfNeeded() } + // MARK: - DataSource-based API + + /// Applies changes from a TiledDataSource. + /// Uses cursor tracking to apply only new changes since last application. + public func applyDataSource(_ dataSource: TiledDataSource) { + // Check if this is a new DataSource instance + if lastDataSourceID != dataSource.id { + lastDataSourceID = dataSource.id + appliedCursor = 0 + tiledLayout.clear() + items = [] + } + + // Apply only changes after the cursor + let pendingChanges = dataSource.pendingChanges + guard appliedCursor < pendingChanges.count else { return } + + let newChanges = pendingChanges[appliedCursor...] + for change in newChanges { + applyChange(change) + } + appliedCursor = pendingChanges.count + } + + private func applyChange(_ change: TiledDataSource.Change) { + switch change { + case .setItems(let newItems): + tiledLayout.clear() + items = newItems + tiledLayout.appendItems(count: newItems.count, startingIndex: 0) + collectionView.reloadData() + + case .prepend(let newItems): + items.insert(contentsOf: newItems, at: 0) + tiledLayout.prependItems(count: newItems.count) + collectionView.reloadData() + + case .append(let newItems): + let startingIndex = items.count + items.append(contentsOf: newItems) + tiledLayout.appendItems(count: newItems.count, startingIndex: startingIndex) + collectionView.reloadData() + + case .update(let updatedItems): + for item in updatedItems { + if let index = items.firstIndex(where: { $0.id == item.id }) { + items[index] = item + } + } + collectionView.reloadData() + + case .remove(let ids): + let idsSet = Set(ids.map { AnyHashable($0) }) + items.removeAll { idsSet.contains(AnyHashable($0.id)) } + // TODO: Update layout to handle removal + collectionView.reloadData() + } + } + // MARK: UICollectionViewDataSource public func numberOfSections(in collectionView: UICollectionView) -> Int { @@ -181,33 +244,28 @@ public final class TiledView: UIView, UICollecti // MARK: - TiledViewRepresentable -public struct TiledViewRepresentable: UIViewRepresentable { +public struct TiledViewRepresentable: UIViewRepresentable { public typealias UIViewType = TiledView - @Binding var tiledView: TiledView? - let items: [Item] + let dataSource: TiledDataSource let cellBuilder: (Item) -> Cell public init( - tiledView: Binding?>, - items: [Item], + dataSource: TiledDataSource, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { - self._tiledView = tiledView - self.items = items + self.dataSource = dataSource self.cellBuilder = cellBuilder } public func makeUIView(context: Context) -> TiledView { let view = TiledView(cellBuilder: cellBuilder) - DispatchQueue.main.async { - tiledView = view - } + view.applyDataSource(dataSource) return view } public func updateUIView(_ uiView: TiledView, context: Context) { - // Items are managed externally via tiledView reference + uiView.applyDataSource(dataSource) } } From d3122794f96cd0c27d6b4609d1aed236032cb283 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 02:40:37 +0900 Subject: [PATCH 08/27] Update --- .../TiledViewDemo.swift | 140 ++++++++++++++---- Package.resolved | 11 +- Package.swift | 4 +- .../Tiled/TiledCollectionViewLayout.swift | 27 ++-- Sources/MessagingUI/Tiled/TiledView.swift | 45 ++---- 5 files changed, 149 insertions(+), 78 deletions(-) diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 578ccc9..bdc3860 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -12,7 +12,8 @@ import MessagingUI struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { let id: Int - let text: String + var text: String + var isExpanded: Bool = false } private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { @@ -39,37 +40,74 @@ private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { // MARK: - Chat Bubble View private struct ChatBubbleView: View { - - @State var isFolded: Bool = false - + let message: ChatMessage + @State private var isLocalExpanded: Bool = false + var body: some View { - Group { - if isFolded { - Text("Tap to open") - } else { + HStack { + VStack(alignment: .leading, spacing: 4) { HStack { - Text(message.text) - .font(.system(size: 16)) - .foregroundStyle(.primary) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6)) - ) - - Spacer(minLength: 44) + Text("ID: \(message.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Image(systemName: isLocalExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(message.text) + .font(.system(size: 16)) + + if message.isExpanded { + Text("(DataSource expanded)") + .font(.system(size: 14)) + .foregroundStyle(.orange) } - } + + if isLocalExpanded { + VStack(alignment: .leading, spacing: 8) { + Text("Local expanded content") + .font(.system(size: 14)) + .foregroundStyle(.blue) + + Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + HStack { + ForEach(0..<3) { i in + Circle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 30, height: 30) + .overlay(Text("\(i + 1)").font(.caption2)) + } + } + } + .padding(.top, 8) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + + Spacer(minLength: 44) } + .contentShape(Rectangle()) .onTapGesture { - withAnimation(.bouncy) { - isFolded.toggle() + withAnimation(.smooth) { + isLocalExpanded.toggle() } } .padding(.horizontal, 16) .padding(.vertical, 8) + .background(Color.init(white: 0.1, opacity: 0.5)) } } @@ -88,12 +126,30 @@ struct BookTiledView: View { var body: some View { VStack(spacing: 0) { + controlPanel + .padding() + .background(Color(.systemBackground)) + + TiledViewRepresentable( + dataSource: dataSource, + cellBuilder: { message in + ChatBubbleView(message: message) + } + ) + } + } + + @ViewBuilder + private var controlPanel: some View { + VStack(spacing: 12) { + // Row 1: Prepend / Append HStack { Button("Prepend 5") { let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) dataSource.prepend(messages) nextPrependId -= 5 } + .buttonStyle(.bordered) Spacer() @@ -102,16 +158,44 @@ struct BookTiledView: View { dataSource.append(messages) nextAppendId += 5 } + .buttonStyle(.bordered) } - .padding() - .background(Color(.systemBackground)) - TiledViewRepresentable( - dataSource: dataSource, - cellBuilder: { message in - ChatBubbleView(message: message) + // Row 2: Update / Remove + HStack { + Button("Update ID:5") { + if var item = dataSource.items.first(where: { $0.id == 5 }) { + item.isExpanded.toggle() + item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" + dataSource.update([item]) + } } - ) + .buttonStyle(.bordered) + + Spacer() + + Button("Remove ID:10") { + dataSource.remove(id: 10) + } + .buttonStyle(.bordered) + } + + // Row 3: SetItems (Reset) + HStack { + Button("Reset (5 items)") { + nextPrependId = -1 + nextAppendId = 5 + let newItems = generateSampleMessages(count: 5, startId: 0) + dataSource.setItems(newItems) + } + .buttonStyle(.borderedProminent) + + Spacer() + + Text("Count: \(dataSource.items.count)") + .font(.caption) + .foregroundStyle(.secondary) + } } } } diff --git a/Package.resolved b/Package.resolved index 3c81661..7f51284 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "ae75b6c63df44fdc6ac5ec3d338d27ea8de305cd24d358a5910dd2172af06d32", + "originHash" : "bc49af4a8f6bc308be3a7ce3e2127a16e109f8eb4ee9dc721a8479a6b3f8c801", "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 8eeb2d6..98e8d1e 100644 --- a/Package.swift +++ b/Package.swift @@ -16,12 +16,14 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/siteline/swiftui-introspect", from: "26.0.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), ], targets: [ .target( name: "MessagingUI", dependencies: [ - .product(name: "SwiftUIIntrospect", package: "swiftui-introspect") + .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), + .product(name: "DequeModule", package: "swift-collections") ] ), .testTarget( diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 03607f8..7b53492 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -5,6 +5,7 @@ // Created by Hiroshi Kimura on 2025/12/10. // +import DequeModule import UIKit // MARK: - TiledCollectionViewLayout @@ -15,7 +16,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { /// Enables caching of layout attributes for better scroll performance. /// When enabled, attributes are reused instead of being recreated on each prepare() call. - public var usesAttributesCache: Bool = true + public var usesAttributesCache: Bool = false /// サイズを問い合わせるclosure。indexとwidthを渡し、サイズを返す。 /// nilを返した場合はestimatedHeightを使用。 @@ -24,8 +25,8 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { // MARK: - Private Properties private var attributesCache: [IndexPath: UICollectionViewLayoutAttributes] = [:] - private var itemYPositions: [CGFloat] = [] - private var itemHeights: [CGFloat] = [] + private var itemYPositions: Deque = [] + private var itemHeights: Deque = [] private var lastPreparedBoundsSize: CGSize = .zero private var needsFullAttributesRebuild: Bool = true @@ -39,9 +40,9 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { ) } -// public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { -// collectionView?.bounds.size != newBounds.size -// } + public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + collectionView?.bounds.size != newBounds.size + } public override func prepare() { guard let collectionView else { return } @@ -122,7 +123,9 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes ) -> Bool { - preferredAttributes.frame.size.height != originalAttributes.frame.size.height + let shouldInvalidate = preferredAttributes.frame.size.height != originalAttributes.frame.size.height + print("[Layout] shouldInvalidateLayout index=\(preferredAttributes.indexPath.item) preferred=\(preferredAttributes.frame.size.height) original=\(originalAttributes.frame.size.height) -> \(shouldInvalidate)") + return shouldInvalidate } public override func invalidationContext( @@ -137,6 +140,8 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { let index = preferredAttributes.indexPath.item let newHeight = preferredAttributes.frame.size.height + print("[Layout] invalidationContext index=\(index) newHeight=\(newHeight) currentStoredHeight=\(index < itemHeights.count ? itemHeights[index] : -1)") + if index < itemHeights.count { updateItemHeight(at: index, newHeight: newHeight) } @@ -176,18 +181,14 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { } } - public func firstItemY() -> CGFloat? { - itemYPositions.first - } - - public func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { + private func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { guard let firstY = itemYPositions.first, let lastY = itemYPositions.last, let lastHeight = itemHeights.last else { return nil } return (firstY, lastY + lastHeight) } - public func calculateContentInset() -> UIEdgeInsets { + private func calculateContentInset() -> UIEdgeInsets { guard let bounds = contentBounds() else { return .zero } let topInset = bounds.top diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index c2f93dc..b962843 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -23,7 +23,6 @@ public final class TiledViewCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() - contentConfiguration = nil } public override func preferredLayoutAttributesFitting( @@ -31,6 +30,12 @@ public final class TiledViewCell: UICollectionViewCell { ) -> UICollectionViewLayoutAttributes { let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes + // MagazineLayout方式: contentViewの幅をlayoutAttributesと同期 + // UIKitのバグでcontentView.boundsがlayoutAttributesと一致しないことがある + if contentView.bounds.width != layoutAttributes.size.width { + contentView.bounds.size.width = layoutAttributes.size.width + } + // UIHostingConfigurationを使用している場合、systemLayoutSizeFittingでサイズを取得 let targetSize = CGSize( width: layoutAttributes.frame.width, @@ -43,6 +48,8 @@ public final class TiledViewCell: UICollectionViewCell { verticalFittingPriority: .fittingSizeLevel ) + print("[Cell] preferredLayoutAttributesFitting index=\(layoutAttributes.indexPath.item) original=\(layoutAttributes.frame.size.height) calculated=\(size.height) contentView.bounds=\(contentView.bounds)") + attributes.frame.size.height = size.height return attributes } @@ -127,38 +134,6 @@ public final class TiledView: UIView return size } - private func centerOnItems() { - guard let firstY = tiledLayout.firstItemY() else { return } - collectionView.contentOffset = CGPoint(x: 0, y: firstY - 100) - } - - public func setItems(_ newItems: [Item]) { - tiledLayout.clear() - items = newItems - tiledLayout.appendItems(count: newItems.count, startingIndex: 0) - collectionView.reloadData() - - DispatchQueue.main.async { [weak self] in - self?.centerOnItems() - } - } - - public func prependItems(_ newItems: [Item]) { - items.insert(contentsOf: newItems, at: 0) - tiledLayout.prependItems(count: newItems.count) - collectionView.reloadData() - collectionView.invalidateIntrinsicContentSize() - collectionView.layoutIfNeeded() - } - - public func appendItems(_ newItems: [Item]) { - let startingIndex = items.count - items.append(contentsOf: newItems) - tiledLayout.appendItems(count: newItems.count, startingIndex: startingIndex) - collectionView.reloadData() - collectionView.layoutIfNeeded() - } - // MARK: - DataSource-based API /// Applies changes from a TiledDataSource. @@ -211,8 +186,8 @@ public final class TiledView: UIView collectionView.reloadData() case .remove(let ids): - let idsSet = Set(ids.map { AnyHashable($0) }) - items.removeAll { idsSet.contains(AnyHashable($0.id)) } + let idsSet = Set(ids) + items.removeAll { idsSet.contains($0.id) } // TODO: Update layout to handle removal collectionView.reloadData() } From 68af2d53ea51b90b35937b529897130d76a82a5d Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 02:58:30 +0900 Subject: [PATCH 09/27] Update --- Sources/MessagingUI/Tiled/TiledView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index b962843..8cd6a0e 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -23,6 +23,7 @@ public final class TiledViewCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() + contentConfiguration = nil } public override func preferredLayoutAttributesFitting( @@ -31,12 +32,10 @@ public final class TiledViewCell: UICollectionViewCell { let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes // MagazineLayout方式: contentViewの幅をlayoutAttributesと同期 - // UIKitのバグでcontentView.boundsがlayoutAttributesと一致しないことがある if contentView.bounds.width != layoutAttributes.size.width { contentView.bounds.size.width = layoutAttributes.size.width } - // UIHostingConfigurationを使用している場合、systemLayoutSizeFittingでサイズを取得 let targetSize = CGSize( width: layoutAttributes.frame.width, height: UIView.layoutFittingCompressedSize.height @@ -48,7 +47,7 @@ public final class TiledViewCell: UICollectionViewCell { verticalFittingPriority: .fittingSizeLevel ) - print("[Cell] preferredLayoutAttributesFitting index=\(layoutAttributes.indexPath.item) original=\(layoutAttributes.frame.size.height) calculated=\(size.height) contentView.bounds=\(contentView.bounds)") + print("[Cell] preferredLayoutAttributesFitting index=\(layoutAttributes.indexPath.item) original=\(layoutAttributes.frame.size.height) calculated=\(size.height)") attributes.frame.size.height = size.height return attributes From 0e1824a4b4d1e23280a5d870a681382c699fd34d Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 03:19:21 +0900 Subject: [PATCH 10/27] Update --- Dev/MessagingUIDevelopment/Cell.swift | 90 +++++++++++++++++++ .../TiledViewDemo.swift | 71 +-------------- 2 files changed, 91 insertions(+), 70 deletions(-) create mode 100644 Dev/MessagingUIDevelopment/Cell.swift diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift new file mode 100644 index 0000000..0ff5557 --- /dev/null +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct ChatBubbleView: View { + + let message: ChatMessage + + @State private var isLocalExpanded: Bool = true + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("ID: \(message.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Image(systemName: isLocalExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(message.text) + .font(.system(size: 16)) + + + if message.isExpanded { + Text("(DataSource expanded)") + .font(.system(size: 14)) + .foregroundStyle(.orange) + } + + if isLocalExpanded { + VStack(alignment: .leading, spacing: 8) { + Text("Local expanded content") + .font(.system(size: 14)) + .foregroundStyle(.blue) + + Text("This is additional.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) +// .fixedSize(horizontal: false, vertical: true) + + HStack { + ForEach(0..<3) { i in + Circle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 30, height: 30) + .overlay(Text("\(i + 1)").font(.caption2)) + } + } + } + +// .padding(.top, 8) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + + Spacer(minLength: 44) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.smooth) { + isLocalExpanded.toggle() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.init(white: 0.1, opacity: 0.5)) + + } +} + +#Preview { + ChatBubbleView( + message: .init( + id: 1, + text: "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。" + ) + ) +} diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index bdc3860..0a500c8 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -21,6 +21,7 @@ private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { "こんにちは!", "今日はいい天気ですね。散歩に行きませんか?", "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt", "了解です👍", "ちょっと待ってください。確認してから返信しますね。", "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", @@ -39,77 +40,7 @@ private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { // MARK: - Chat Bubble View -private struct ChatBubbleView: View { - let message: ChatMessage - - @State private var isLocalExpanded: Bool = false - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("ID: \(message.id)") - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - Image(systemName: isLocalExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - - Text(message.text) - .font(.system(size: 16)) - - if message.isExpanded { - Text("(DataSource expanded)") - .font(.system(size: 14)) - .foregroundStyle(.orange) - } - - if isLocalExpanded { - VStack(alignment: .leading, spacing: 8) { - Text("Local expanded content") - .font(.system(size: 14)) - .foregroundStyle(.blue) - - Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - - HStack { - ForEach(0..<3) { i in - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 30, height: 30) - .overlay(Text("\(i + 1)").font(.caption2)) - } - } - } - .padding(.top, 8) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6)) - ) - - Spacer(minLength: 44) - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.smooth) { - isLocalExpanded.toggle() - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.init(white: 0.1, opacity: 0.5)) - } -} // MARK: - Demo View From b548dee1aeefa5dfa03d91ae7f4b02943b391e8b Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 03:32:00 +0900 Subject: [PATCH 11/27] Update --- Dev/MessagingUIDevelopment/Cell.swift | 67 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index 0ff5557..5178d63 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -32,14 +32,10 @@ struct ChatBubbleView: View { } if isLocalExpanded { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { Text("Local expanded content") .font(.system(size: 14)) .foregroundStyle(.blue) - - Text("This is additional.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") .font(.system(size: 12)) @@ -54,9 +50,8 @@ struct ChatBubbleView: View { .overlay(Text("\(i + 1)").font(.caption2)) } } - } - -// .padding(.top, 8) + } + .padding(.top, 8) } } .padding(12) @@ -80,7 +75,7 @@ struct ChatBubbleView: View { } } -#Preview { +#Preview("SwiftUI Direct") { ChatBubbleView( message: .init( id: 1, @@ -88,3 +83,57 @@ struct ChatBubbleView: View { ) ) } +struct HostingControllerWrapper: UIViewControllerRepresentable { + let content: Content + + func makeUIViewController(context: Context) -> UIHostingController { + let hostingController = UIHostingController(rootView: content) + hostingController.view.backgroundColor = .systemBackground + hostingController.sizingOptions = .intrinsicContentSize + hostingController.view + .setContentCompressionResistancePriority(.required, for: .vertical) + hostingController.view.backgroundColor = .clear + hostingController.safeAreaRegions = [] + + return hostingController + } + + func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { + uiViewController.rootView = content + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: Self.UIViewControllerType, context: Self.Context) -> CGSize? { + + var size = uiViewController.sizeThatFits( + in: CGSize( + width: proposal.width ?? UIView.layoutFittingCompressedSize.width, + height: proposal.height ?? UIView.layoutFittingCompressedSize.height + ) + ) +// size.height += 80 + print(size) + + + return size + + + } +} +#Preview("UIHostingController") { + + + ZStack { + HostingControllerWrapper( + content: ChatBubbleView( + message: .init( + id: 1, + text: "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。" + ) + ) + ) + } + .background(.red) + .onGeometryChange(for: CGSize.self, of: \.size) { n in + print("Size changed: \(n)") + } +} From 03b84c4d02203047614ce96546b97484874c39db Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 12:19:15 +0900 Subject: [PATCH 12/27] Update --- Dev/MessagingUIDevelopment/BookChat.swift | 68 +++--- Dev/MessagingUIDevelopment/Cell.swift | 34 ++- Dev/MessagingUIDevelopment/ContentView.swift | 97 +++++++- .../TiledViewDemo.swift | 223 +++++++++++++----- .../OlderMessagesLoadingModifier.swift | 14 +- ...dDataSource.swift => ListDataSource.swift} | 50 ++-- Sources/MessagingUI/MessageList.swift | 89 ++++--- Sources/MessagingUI/Tiled/TiledView.swift | 12 +- 8 files changed, 402 insertions(+), 185 deletions(-) rename Sources/MessagingUI/{Tiled/TiledDataSource.swift => ListDataSource.swift} (69%) diff --git a/Dev/MessagingUIDevelopment/BookChat.swift b/Dev/MessagingUIDevelopment/BookChat.swift index db756c2..a3af365 100644 --- a/Dev/MessagingUIDevelopment/BookChat.swift +++ b/Dev/MessagingUIDevelopment/BookChat.swift @@ -9,7 +9,7 @@ private enum MessageSender { case other } -private struct PreviewMessage: Identifiable { +private struct PreviewMessage: Identifiable, Equatable, Hashable { let id: UUID let text: String let sender: MessageSender @@ -22,12 +22,12 @@ private struct PreviewMessage: Identifiable { } struct MessageListPreviewContainer: View { - @State private var messages: [PreviewMessage] = [ + @State private var dataSource = ListDataSource(items: [ 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 @@ -62,27 +62,14 @@ struct MessageListPreviewContainer: View { Toggle("Auto-scroll to new messages", isOn: $autoScrollToBottom) .font(.caption) - Text("Scroll up to load older messages") + Text("Use buttons to add 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) - } + dataSource: dataSource, + autoScrollToBottom: $autoScrollToBottom ) { message in Text(message.text) .padding(12) @@ -92,38 +79,35 @@ struct MessageListPreviewContainer: View { } 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)) + Button("Prepend 5") { + 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) + } + dataSource.prepend(newMessages) } - .buttonStyle(.borderedProminent) + .buttonStyle(.bordered) - Button("Add Old Message") { - count += 1 + Button("Append 5") { + 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) + } + dataSource.append(newMessages) } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) Button("Clear All", role: .destructive) { - messages.removeAll() + dataSource.setItems([]) } .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") { @@ -131,12 +115,14 @@ struct MessageListPreviewContainer: View { } #Preview("Simple Preview") { - MessageList(messages: [ + @Previewable @State var dataSource = ListDataSource(items: [ 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 + ]) + + MessageList(dataSource: dataSource) { message in Text(message.text) .padding(12) .background(message.sender == .me ? Color.green.opacity(0.2) : Color.blue.opacity(0.1)) diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index 5178d63..7b6bdc7 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -1,5 +1,37 @@ import SwiftUI +// MARK: - Sample Data + +struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { + let id: Int + var text: String + var isExpanded: Bool = false +} + +func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { + let sampleTexts = [ + "こんにちは!", + "今日はいい天気ですね。散歩に行きませんか?", + "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt", + "了解です👍", + "ちょっと待ってください。確認してから返信しますね。", + "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", + "OK", + "今から出発します!", + "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "🎉🎊✨", + ] + + return (0..: UIViewControllerRepresentable { func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: Self.UIViewControllerType, context: Self.Context) -> CGSize? { - var size = uiViewController.sizeThatFits( + let size = uiViewController.sizeThatFits( in: CGSize( width: proposal.width ?? UIView.layoutFittingCompressedSize.width, height: proposal.height ?? UIView.layoutFittingCompressedSize.height diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 85b94df..4ee0211 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -10,18 +10,103 @@ import SwiftUI struct ContentView: View { var body: some View { NavigationStack { - Form { - NavigationLink("Message List Preview") { - MessageListPreviewContainer() + List { + Section("List Implementation Comparison") { + NavigationLink { + BookSideBySideComparison() + .navigationTitle("Side by Side") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("Side by Side") + Text("Same DataSource, split view") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "rectangle.split.2x1") + } + } + + NavigationLink { + BookListComparison() + .navigationTitle("Tab Comparison") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("Tab Comparison") + Text("Separate DataSource, tab switch") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.left.arrow.right") + } + } } - NavigationLink("BookTiledView") { - BookTiledView() + + Section("Individual Demos") { + NavigationLink { + BookTiledView() + .navigationTitle("TiledView") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("TiledView") + Text("UICollectionView based") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "square.grid.2x2") + } + } + + NavigationLink { + BookMessageList() + .navigationTitle("MessageList") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("MessageList") + Text("LazyVStack based") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "list.bullet") + } + } + } + + Section("Other Examples") { + NavigationLink { + MessageListPreviewContainer() + .navigationTitle("Chat Style") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("Chat Style Demo") + Text("Simple chat bubbles") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "bubble.left.and.bubble.right") + } + } } } + .navigationTitle("MessagingUI") } } } -#Preview { +#Preview { ContentView() } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 0a500c8..af71a7f 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -8,70 +8,15 @@ import SwiftUI import MessagingUI -// MARK: - Sample Data - -struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { - let id: Int - var text: String - var isExpanded: Bool = false -} - -private func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { - let sampleTexts = [ - "こんにちは!", - "今日はいい天気ですね。散歩に行きませんか?", - "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt", - "了解です👍", - "ちょっと待ってください。確認してから返信しますね。", - "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", - "OK", - "今から出発します!", - "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", - "🎉🎊✨", - ] - - return (0.. - @State private var nextPrependId = -1 - @State private var nextAppendId = 20 - - init() { - let initial = generateSampleMessages(count: 20, startId: 0) - _dataSource = State(initialValue: TiledDataSource(items: initial)) - } + @Binding var dataSource: ListDataSource + @Binding var nextPrependId: Int + @Binding var nextAppendId: Int var body: some View { - VStack(spacing: 0) { - controlPanel - .padding() - .background(Color(.systemBackground)) - - TiledViewRepresentable( - dataSource: dataSource, - cellBuilder: { message in - ChatBubbleView(message: message) - } - ) - } - } - - @ViewBuilder - private var controlPanel: some View { VStack(spacing: 12) { // Row 1: Prepend / Append HStack { @@ -131,6 +76,162 @@ struct BookTiledView: View { } } -#Preview("TiledView Demo") { +// MARK: - TiledView Demo (UICollectionView) + +struct BookTiledView: View { + + @State private var dataSource: ListDataSource + @State private var nextPrependId = -1 + @State private var nextAppendId = 20 + + init() { + let initial = generateSampleMessages(count: 20, startId: 0) + _dataSource = State(initialValue: ListDataSource(items: initial)) + } + + var body: some View { + VStack(spacing: 0) { + ListDemoControlPanel( + dataSource: $dataSource, + nextPrependId: $nextPrependId, + nextAppendId: $nextAppendId + ) + .padding() + .background(Color(.systemBackground)) + + TiledViewRepresentable( + dataSource: dataSource, + cellBuilder: { message in + ChatBubbleView(message: message) + } + ) + } + } +} + +// MARK: - MessageList Demo (LazyVStack) + +struct BookMessageList: View { + + @State private var dataSource: ListDataSource + @State private var nextPrependId = -1 + @State private var nextAppendId = 20 + + init() { + let initial = generateSampleMessages(count: 20, startId: 0) + _dataSource = State(initialValue: ListDataSource(items: initial)) + } + + var body: some View { + VStack(spacing: 0) { + ListDemoControlPanel( + dataSource: $dataSource, + nextPrependId: $nextPrependId, + nextAppendId: $nextAppendId + ) + .padding() + .background(Color(.systemBackground)) + + MessageList( + dataSource: dataSource + ) { message in + ChatBubbleView(message: message) + } + } + } +} + +// MARK: - Comparison Demo (TabView) + +struct BookListComparison: View { + + var body: some View { + TabView { + BookTiledView() + .tabItem { + Label("TiledView", systemImage: "square.grid.2x2") + } + + BookMessageList() + .tabItem { + Label("MessageList", systemImage: "list.bullet") + } + } + } +} + +// MARK: - Side-by-Side Comparison (Same DataSource) + +struct BookSideBySideComparison: View { + + @State private var dataSource: ListDataSource + @State private var nextPrependId = -1 + @State private var nextAppendId = 20 + + init() { + let initial = generateSampleMessages(count: 20, startId: 0) + _dataSource = State(initialValue: ListDataSource(items: initial)) + } + + var body: some View { + VStack(spacing: 0) { + // Shared Control Panel + ListDemoControlPanel( + dataSource: $dataSource, + nextPrependId: $nextPrependId, + nextAppendId: $nextAppendId + ) + .padding() + .background(Color(.systemBackground)) + + // Side-by-side views + HStack(spacing: 1) { + // Left: TiledView (UICollectionView) + VStack(spacing: 0) { + Text("TiledView") + .font(.caption.bold()) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + + TiledViewRepresentable( + dataSource: dataSource, + cellBuilder: { message in + ChatBubbleView(message: message) + } + ) + } + + // Right: MessageList (LazyVStack) + VStack(spacing: 0) { + Text("MessageList") + .font(.caption.bold()) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background(Color.green.opacity(0.2)) + + MessageList(dataSource: dataSource) { message in + ChatBubbleView(message: message) + } + } + } + .background(Color(.separator)) + } + } +} + +#Preview("Side by Side") { + BookSideBySideComparison() +} + +#Preview("TiledView (UICollectionView)") { BookTiledView() } + +#Preview("MessageList (LazyVStack)") { + BookMessageList() +} + +#Preview("Comparison") { + BookListComparison() +} diff --git a/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift index 572c8ea..bb5921a 100644 --- a/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift +++ b/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift @@ -14,18 +14,24 @@ struct _OlderMessagesLoadingModifier: ViewModifier { private let autoScrollToBottom: Binding? private let onLoadOlderMessages: (@MainActor () async -> Void)? + private let lastChangeType: ListDataSourceChangeType? private let leadingScreens: CGFloat = 1.0 nonisolated init( autoScrollToBottom: Binding?, - onLoadOlderMessages: (@MainActor () async -> Void)? + onLoadOlderMessages: (@MainActor () async -> Void)?, + lastChangeType: ListDataSourceChangeType? = nil ) { self.autoScrollToBottom = autoScrollToBottom self.onLoadOlderMessages = onLoadOlderMessages + self.lastChangeType = lastChangeType } func body(content: Content) -> some View { - if onLoadOlderMessages != nil { + // Apply scroll position preservation if either: + // 1. onLoadOlderMessages is provided (legacy API) + // 2. lastChangeType is provided (DataSource API) + if onLoadOlderMessages != nil || lastChangeType != nil { if #available(iOS 18.0, macOS 15.0, *) { content .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in @@ -149,7 +155,9 @@ struct _OlderMessagesLoadingModifier: ViewModifier { let boundsHeight = scrollView.bounds.height // Case 1: Loading older messages → preserve scroll position (highest priority) - if isBackwardLoading { + // Use lastChangeType from DataSource if available, otherwise fall back to isBackwardLoading flag + let isPrepending = lastChangeType == .prepend || isBackwardLoading + if isPrepending { let newOffset = currentOffset + heightDiff scrollView.contentOffset.y = newOffset } diff --git a/Sources/MessagingUI/Tiled/TiledDataSource.swift b/Sources/MessagingUI/ListDataSource.swift similarity index 69% rename from Sources/MessagingUI/Tiled/TiledDataSource.swift rename to Sources/MessagingUI/ListDataSource.swift index a01a4b3..531f6ee 100644 --- a/Sources/MessagingUI/Tiled/TiledDataSource.swift +++ b/Sources/MessagingUI/ListDataSource.swift @@ -1,20 +1,21 @@ // -// TiledDataSource.swift +// ListDataSource.swift // MessagingUI // // Created by Hiroshi Kimura on 2025/12/10. // +import DequeModule import Foundation -// MARK: - TiledDataSource +// MARK: - ListDataSource -/// A data source for TiledView that tracks changes for efficient updates. +/// A data source that tracks changes for efficient list updates. /// /// Instead of directly modifying an array, use this data source's methods -/// to modify items. This allows TiledView to know exactly what changed -/// and update the layout accordingly without adjusting content offset. -public struct TiledDataSource: Equatable { +/// to modify items. This allows list views to know exactly what changed +/// and update accordingly without adjusting content offset. +public struct ListDataSource: Equatable { // MARK: - Change @@ -35,8 +36,11 @@ public struct TiledDataSource: Equatable { /// Change counter used as cursor for tracking applied changes. public private(set) var changeCounter: Int = 0 + /// Internal storage using Deque for efficient prepend operations. + private var _items: Deque = [] + /// The current items in the data source. - public private(set) var items: [Item] = [] + public var items: [Item] { Array(_items) } /// Pending changes that haven't been consumed by TiledView yet. internal private(set) var pendingChanges: [Change] = [] @@ -46,7 +50,7 @@ public struct TiledDataSource: Equatable { public init() {} public init(items: [Item]) { - self.items = items + self._items = Deque(items) self.pendingChanges = [.setItems(items)] self.changeCounter = 1 } @@ -56,7 +60,7 @@ public struct TiledDataSource: Equatable { /// Sets all items, replacing any existing items. /// Use this for initial load or complete refresh. public mutating func setItems(_ items: [Item]) { - self.items = items + self._items = Deque(items) pendingChanges.append(.setItems(items)) changeCounter += 1 } @@ -65,7 +69,9 @@ public struct TiledDataSource: Equatable { /// Use this for loading older content (e.g., older messages). public mutating func prepend(_ items: [Item]) { guard !items.isEmpty else { return } - self.items.insert(contentsOf: items, at: 0) + for item in items.reversed() { + self._items.prepend(item) + } pendingChanges.append(.prepend(items)) changeCounter += 1 } @@ -74,7 +80,7 @@ public struct TiledDataSource: Equatable { /// Use this for loading newer content (e.g., new messages). public mutating func append(_ items: [Item]) { guard !items.isEmpty else { return } - self.items.append(contentsOf: items) + self._items.append(contentsOf: items) pendingChanges.append(.append(items)) changeCounter += 1 } @@ -85,8 +91,8 @@ public struct TiledDataSource: Equatable { guard !items.isEmpty else { return } var updatedItems: [Item] = [] for item in items { - if let index = self.items.firstIndex(where: { $0.id == item.id }) { - self.items[index] = item + if let index = self._items.firstIndex(where: { $0.id == item.id }) { + self._items[index] = item updatedItems.append(item) } } @@ -100,8 +106,8 @@ public struct TiledDataSource: Equatable { public mutating func remove(ids: [Item.ID]) { guard !ids.isEmpty else { return } let idsSet = Set(ids) - let removedIds = items.filter { idsSet.contains($0.id) }.map { $0.id } - self.items.removeAll { idsSet.contains($0.id) } + let removedIds = _items.filter { idsSet.contains($0.id) }.map { $0.id } + self._items.removeAll { idsSet.contains($0.id) } if !removedIds.isEmpty { pendingChanges.append(.remove(removedIds)) changeCounter += 1 @@ -115,7 +121,7 @@ public struct TiledDataSource: Equatable { // MARK: - Equatable - public static func == (lhs: TiledDataSource, rhs: TiledDataSource) -> Bool { + public static func == (lhs: ListDataSource, rhs: ListDataSource) -> Bool { // Compare id and items, not pendingChanges or changeCounter // Different id means different data source instance lhs.id == rhs.id && lhs.items == rhs.items @@ -124,16 +130,22 @@ public struct TiledDataSource: Equatable { // MARK: - Item.ID Hashable conformance for Set operations -extension TiledDataSource where Item.ID: Hashable { +extension ListDataSource where Item.ID: Hashable { /// Removes items with the specified IDs (optimized for Hashable IDs). public mutating func remove(ids: Set) { guard !ids.isEmpty else { return } - let removedIds = items.filter { ids.contains($0.id) }.map { $0.id } - self.items.removeAll { ids.contains($0.id) } + let removedIds = _items.filter { ids.contains($0.id) }.map { $0.id } + self._items.removeAll { ids.contains($0.id) } if !removedIds.isEmpty { pendingChanges.append(.remove(removedIds)) changeCounter += 1 } } } + +// MARK: - Backward Compatibility + +/// Backward compatibility alias for TiledDataSource. +@available(*, deprecated, renamed: "ListDataSource") +public typealias TiledDataSource = ListDataSource diff --git a/Sources/MessagingUI/MessageList.swift b/Sources/MessagingUI/MessageList.swift index 8bdc622..9231462 100644 --- a/Sources/MessagingUI/MessageList.swift +++ b/Sources/MessagingUI/MessageList.swift @@ -9,6 +9,15 @@ import SwiftUI import SwiftUIIntrospect import Combine +/// Change type for MessageList to track prepend/append operations. +public enum ListDataSourceChangeType: Equatable, Sendable { + case setItems + case prepend + case append + case update + case remove +} + /// # Spec /// /// - `MessageList` is a generic, scrollable message list component that displays messages using a custom view builder. @@ -18,83 +27,64 @@ import Combine /// ## Usage /// /// ```swift -/// MessageList(messages: messages) { message in +/// @State private var dataSource = ListDataSource(items: messages) +/// +/// MessageList(dataSource: dataSource) { message in /// Text(message.text) /// .padding(12) /// .background(Color.blue.opacity(0.1)) /// .cornerRadius(8) /// } /// ``` -public struct MessageList: View { +public struct MessageList: View { - public let messages: [Message] + private let dataSource: ListDataSource 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 + private var lastChangeType: ListDataSourceChangeType? { + dataSource.pendingChanges.last.map { change in + switch change { + case .setItems: return .setItems + case .prepend: return .prepend + case .append: return .append + case .update: return .update + case .remove: return .remove + } + } } - /// Creates a message list with older message loading support. + /// Creates a message list using a ListDataSource for change tracking. + /// + /// This initializer automatically detects prepend/append operations from the + /// data source's change history, enabling proper scroll position preservation. /// /// - Parameters: - /// - messages: Array of messages to display. Must conform to `Identifiable`. + /// - dataSource: A ListDataSource that tracks changes for efficient updates. /// - 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], + dataSource: ListDataSource, autoScrollToBottom: Binding? = nil, - onLoadOlderMessages: @escaping @MainActor () async -> Void, @ViewBuilder content: @escaping (Message) -> Content ) { - self.messages = messages + self.dataSource = dataSource 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)] - } + ForEach(dataSource.items) { 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)] - } - } } } } @@ -139,7 +129,8 @@ public struct MessageList: View { .modifier( _OlderMessagesLoadingModifier( autoScrollToBottom: autoScrollToBottom, - onLoadOlderMessages: onLoadOlderMessages + onLoadOlderMessages: nil, + lastChangeType: lastChangeType ) ) } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 8cd6a0e..d159496 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -71,6 +71,8 @@ public final class TiledView: UIView private var lastDataSourceID: UUID? private var appliedCursor: Int = 0 + public typealias DataSource = ListDataSource + public var onPrepend: (() -> Void)? public var onAppend: (() -> Void)? @@ -135,9 +137,9 @@ public final class TiledView: UIView // MARK: - DataSource-based API - /// Applies changes from a TiledDataSource. + /// Applies changes from a ListDataSource. /// Uses cursor tracking to apply only new changes since last application. - public func applyDataSource(_ dataSource: TiledDataSource) { + public func applyDataSource(_ dataSource: ListDataSource) { // Check if this is a new DataSource instance if lastDataSourceID != dataSource.id { lastDataSourceID = dataSource.id @@ -157,7 +159,7 @@ public final class TiledView: UIView appliedCursor = pendingChanges.count } - private func applyChange(_ change: TiledDataSource.Change) { + private func applyChange(_ change: ListDataSource.Change) { switch change { case .setItems(let newItems): tiledLayout.clear() @@ -222,11 +224,11 @@ public struct TiledViewRepresentable public typealias UIViewType = TiledView - let dataSource: TiledDataSource + let dataSource: ListDataSource let cellBuilder: (Item) -> Cell public init( - dataSource: TiledDataSource, + dataSource: ListDataSource, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self.dataSource = dataSource From f834764c1b1db933de1879a861e445d17c4f6b13 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 16:18:40 +0900 Subject: [PATCH 13/27] Update --- .../TiledViewDemo.swift | 58 +++-- .../Internal/AutoScrollToBottomModifier.swift | 96 ++++++++ .../OlderMessagesLoadingController.swift | 28 --- .../OlderMessagesLoadingModifier.swift | 219 ------------------ .../ScrollPositionPreservingModifier.swift | 93 ++++++++ Sources/MessagingUI/ListDataSource.swift | 26 ++- Sources/MessagingUI/MessageList.swift | 132 ++++++----- Sources/MessagingUI/Tiled/TiledView.swift | 25 +- 8 files changed, 332 insertions(+), 345 deletions(-) create mode 100644 Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift delete mode 100644 Sources/MessagingUI/Internal/OlderMessagesLoadingController.swift delete mode 100644 Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift create mode 100644 Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index af71a7f..b7abeee 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -56,7 +56,38 @@ struct ListDemoControlPanel: View { .buttonStyle(.bordered) } - // Row 3: SetItems (Reset) + // Row 3: Batch operations (multiple pendingChanges) + HStack { + Button("Prepend+Append") { + // Creates 2 pendingChanges at once + let prependMessages = generateSampleMessages(count: 3, startId: nextPrependId - 2) + dataSource.prepend(prependMessages) + nextPrependId -= 3 + + let appendMessages = generateSampleMessages(count: 3, startId: nextAppendId) + dataSource.append(appendMessages) + nextAppendId += 3 + } + .buttonStyle(.bordered) + .tint(.orange) + + Spacer() + + Button("Append+Prepend") { + // Creates 2 pendingChanges (append first, then prepend) + let appendMessages = generateSampleMessages(count: 3, startId: nextAppendId) + dataSource.append(appendMessages) + nextAppendId += 3 + + let prependMessages = generateSampleMessages(count: 3, startId: nextPrependId - 2) + dataSource.prepend(prependMessages) + nextPrependId -= 3 + } + .buttonStyle(.bordered) + .tint(.orange) + } + + // Row 4: SetItems (Reset) + Debug info HStack { Button("Reset (5 items)") { nextPrependId = -1 @@ -68,9 +99,13 @@ struct ListDemoControlPanel: View { Spacer() - Text("Count: \(dataSource.items.count)") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .trailing, spacing: 2) { + Text("Count: \(dataSource.items.count)") + .font(.caption) + Text("ChangeCounter: \(dataSource.changeCounter)") + .font(.caption2) + .foregroundStyle(.secondary) + } } } } @@ -82,11 +117,10 @@ struct BookTiledView: View { @State private var dataSource: ListDataSource @State private var nextPrependId = -1 - @State private var nextAppendId = 20 + @State private var nextAppendId = 0 init() { - let initial = generateSampleMessages(count: 20, startId: 0) - _dataSource = State(initialValue: ListDataSource(items: initial)) + _dataSource = State(initialValue: ListDataSource()) } var body: some View { @@ -115,11 +149,10 @@ struct BookMessageList: View { @State private var dataSource: ListDataSource @State private var nextPrependId = -1 - @State private var nextAppendId = 20 + @State private var nextAppendId = 0 init() { - let initial = generateSampleMessages(count: 20, startId: 0) - _dataSource = State(initialValue: ListDataSource(items: initial)) + _dataSource = State(initialValue: ListDataSource()) } var body: some View { @@ -166,11 +199,10 @@ struct BookSideBySideComparison: View { @State private var dataSource: ListDataSource @State private var nextPrependId = -1 - @State private var nextAppendId = 20 + @State private var nextAppendId = 0 init() { - let initial = generateSampleMessages(count: 20, startId: 0) - _dataSource = State(initialValue: ListDataSource(items: initial)) + _dataSource = State(initialValue: ListDataSource()) } var body: some View { diff --git a/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift b/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift new file mode 100644 index 0000000..ea9c30f --- /dev/null +++ b/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift @@ -0,0 +1,96 @@ +// +// AutoScrollToBottomModifier.swift +// MessagingUI +// +// Created by Hiroshi Kimura on 2025/12/11. +// + +import SwiftUI +import SwiftUIIntrospect +import UIKit + +/// A view modifier that automatically scrolls to the bottom when content is added. +/// +/// When enabled and new content increases the scroll view's content size, +/// this modifier animates the scroll position to the bottom. +struct _AutoScrollToBottomModifier: ViewModifier { + + @StateObject private var controller = _AutoScrollToBottomController() + + private let isEnabled: Binding? + + init(isEnabled: Binding?) { + self.isEnabled = isEnabled + } + + func body(content: Content) -> some View { + Group { + if #available(iOS 18.0, macOS 15.0, *) { + content + .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in + setupContentSizeObservation(scrollView: scrollView) + } + } else { + content + .introspect(.scrollView, on: .iOS(.v17)) { scrollView in + setupContentSizeObservation(scrollView: scrollView) + } + } + } + .onAppear { + controller.isEnabled = isEnabled + } + } + + @MainActor + private func setupContentSizeObservation(scrollView: UIScrollView) { + guard controller.scrollViewRef !== scrollView else { return } + + controller.scrollViewRef = scrollView + controller.contentSizeObservation?.invalidate() + + let controller = self.controller + + 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 and auto-scroll is enabled + if heightDiff > 0, + let isEnabled = controller.isEnabled, + isEnabled.wrappedValue + { + let boundsHeight = scrollView.bounds.height + let bottomOffset = newHeight - boundsHeight + UIView.animate(withDuration: 0.3) { + scrollView.contentOffset.y = max(0, bottomOffset) + } + } + } + } + } +} + +@MainActor +private final class _AutoScrollToBottomController: ObservableObject { + weak var scrollViewRef: UIScrollView? + var contentSizeObservation: NSKeyValueObservation? + var isEnabled: Binding? +} + +// MARK: - View Extension + +extension View { + /// Automatically scrolls to the bottom when content is added. + /// + /// - Parameter isEnabled: Binding that controls whether auto-scroll is active. + func autoScrollToBottom(isEnabled: Binding?) -> some View { + modifier(_AutoScrollToBottomModifier(isEnabled: isEnabled)) + } +} diff --git a/Sources/MessagingUI/Internal/OlderMessagesLoadingController.swift b/Sources/MessagingUI/Internal/OlderMessagesLoadingController.swift deleted file mode 100644 index a31606e..0000000 --- a/Sources/MessagingUI/Internal/OlderMessagesLoadingController.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// OlderMessagesLoadingController.swift -// swiftui-messaging-ui -// -// 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/MessagingUI/Internal/OlderMessagesLoadingModifier.swift b/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift deleted file mode 100644 index bb5921a..0000000 --- a/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift +++ /dev/null @@ -1,219 +0,0 @@ -// -// OlderMessagesLoadingModifier.swift -// swiftui-messaging-ui -// -// 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 lastChangeType: ListDataSourceChangeType? - private let leadingScreens: CGFloat = 1.0 - - nonisolated init( - autoScrollToBottom: Binding?, - onLoadOlderMessages: (@MainActor () async -> Void)?, - lastChangeType: ListDataSourceChangeType? = nil - ) { - self.autoScrollToBottom = autoScrollToBottom - self.onLoadOlderMessages = onLoadOlderMessages - self.lastChangeType = lastChangeType - } - - func body(content: Content) -> some View { - // Apply scroll position preservation if either: - // 1. onLoadOlderMessages is provided (legacy API) - // 2. lastChangeType is provided (DataSource API) - if onLoadOlderMessages != nil || lastChangeType != 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) - // Use lastChangeType from DataSource if available, otherwise fall back to isBackwardLoading flag - let isPrepending = lastChangeType == .prepend || isBackwardLoading - if isPrepending { - 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/MessagingUI/Internal/ScrollPositionPreservingModifier.swift b/Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift new file mode 100644 index 0000000..46acdb7 --- /dev/null +++ b/Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift @@ -0,0 +1,93 @@ +// +// ScrollPositionPreservingModifier.swift +// MessagingUI +// +// Created by Hiroshi Kimura on 2025/12/11. +// + +import SwiftUI +import SwiftUIIntrospect +import UIKit + +/// A view modifier that preserves scroll position when content is prepended. +/// +/// When new content is added at the top of a scroll view, this modifier adjusts +/// the content offset to maintain the user's current scroll position. +struct _ScrollPositionPreservingModifier: ViewModifier { + + @StateObject private var controller = _ScrollPositionPreservingController() + + private let isPrepending: Bool + + init(isPrepending: Bool) { + self.isPrepending = isPrepending + } + + func body(content: Content) -> some View { + Group { + if #available(iOS 18.0, macOS 15.0, *) { + content + .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in + setupContentSizeObservation(scrollView: scrollView) + } + } else { + content + .introspect(.scrollView, on: .iOS(.v17)) { scrollView in + setupContentSizeObservation(scrollView: scrollView) + } + } + } + .onChange(of: isPrepending) { _, newValue in + controller.isPrepending = newValue + } + .onAppear { + controller.isPrepending = isPrepending + } + } + + @MainActor + private func setupContentSizeObservation(scrollView: UIScrollView) { + guard controller.scrollViewRef !== scrollView else { return } + + controller.scrollViewRef = scrollView + controller.contentSizeObservation?.invalidate() + + let controller = self.controller + + 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 and we're prepending + if heightDiff > 0 && controller.isPrepending { + let newOffset = scrollView.contentOffset.y + heightDiff + scrollView.contentOffset.y = newOffset + } + } + } + } +} + +@MainActor +private final class _ScrollPositionPreservingController: ObservableObject { + weak var scrollViewRef: UIScrollView? + var contentSizeObservation: NSKeyValueObservation? + var isPrepending: Bool = false +} + +// MARK: - View Extension + +extension View { + /// Preserves scroll position when content is prepended. + /// + /// - Parameter isPrepending: Whether content is currently being prepended. + func scrollPositionPreserving(isPrepending: Bool) -> some View { + modifier(_ScrollPositionPreservingModifier(isPrepending: isPrepending)) + } +} diff --git a/Sources/MessagingUI/ListDataSource.swift b/Sources/MessagingUI/ListDataSource.swift index 531f6ee..b570bce 100644 --- a/Sources/MessagingUI/ListDataSource.swift +++ b/Sources/MessagingUI/ListDataSource.swift @@ -20,10 +20,10 @@ public struct ListDataSource: Equatable { // MARK: - Change public enum Change: Equatable { - case setItems([Item]) - case prepend([Item]) - case append([Item]) - case update([Item]) + case setItems + case prepend([Item.ID]) + case append([Item.ID]) + case update([Item.ID]) case remove([Item.ID]) } @@ -51,7 +51,7 @@ public struct ListDataSource: Equatable { public init(items: [Item]) { self._items = Deque(items) - self.pendingChanges = [.setItems(items)] + self.pendingChanges = [.setItems] self.changeCounter = 1 } @@ -61,7 +61,7 @@ public struct ListDataSource: Equatable { /// Use this for initial load or complete refresh. public mutating func setItems(_ items: [Item]) { self._items = Deque(items) - pendingChanges.append(.setItems(items)) + pendingChanges.append(.setItems) changeCounter += 1 } @@ -69,10 +69,11 @@ public struct ListDataSource: Equatable { /// Use this for loading older content (e.g., older messages). public mutating func prepend(_ items: [Item]) { guard !items.isEmpty else { return } + let ids = items.map { $0.id } for item in items.reversed() { self._items.prepend(item) } - pendingChanges.append(.prepend(items)) + pendingChanges.append(.prepend(ids)) changeCounter += 1 } @@ -80,8 +81,9 @@ public struct ListDataSource: Equatable { /// Use this for loading newer content (e.g., new messages). public mutating func append(_ items: [Item]) { guard !items.isEmpty else { return } + let ids = items.map { $0.id } self._items.append(contentsOf: items) - pendingChanges.append(.append(items)) + pendingChanges.append(.append(ids)) changeCounter += 1 } @@ -89,15 +91,15 @@ public struct ListDataSource: Equatable { /// Items that don't exist in the current list are ignored. public mutating func update(_ items: [Item]) { guard !items.isEmpty else { return } - var updatedItems: [Item] = [] + var updatedIds: [Item.ID] = [] for item in items { if let index = self._items.firstIndex(where: { $0.id == item.id }) { self._items[index] = item - updatedItems.append(item) + updatedIds.append(item.id) } } - if !updatedItems.isEmpty { - pendingChanges.append(.update(updatedItems)) + if !updatedIds.isEmpty { + pendingChanges.append(.update(updatedIds)) changeCounter += 1 } } diff --git a/Sources/MessagingUI/MessageList.swift b/Sources/MessagingUI/MessageList.swift index 9231462..7c21415 100644 --- a/Sources/MessagingUI/MessageList.swift +++ b/Sources/MessagingUI/MessageList.swift @@ -42,18 +42,6 @@ public struct MessageList: Vie private let content: (Message) -> Content private let autoScrollToBottom: Binding? - private var lastChangeType: ListDataSourceChangeType? { - dataSource.pendingChanges.last.map { change in - switch change { - case .setItems: return .setItems - case .prepend: return .prepend - case .append: return .append - case .update: return .update - case .remove: return .remove - } - } - } - /// Creates a message list using a ListDataSource for change tracking. /// /// This initializer automatically detects prepend/append operations from the @@ -74,66 +62,86 @@ public struct MessageList: Vie } public var body: some View { + _MessageListContent( + dataSource: dataSource, + autoScrollToBottom: autoScrollToBottom, + content: content + ) + } +} + +// MARK: - Internal Content View with State + +private struct _MessageListContent: View { + + let dataSource: ListDataSource + let autoScrollToBottom: Binding? + let content: (Message) -> Content + + /// Tracks which changes have been applied (cursor into pendingChanges) + @State private var appliedCursor: Int = 0 + /// Tracks the last DataSource ID to detect replacement + @State private var lastDataSourceID: UUID? + + /// Computes the change type for unapplied changes. + /// Prioritizes prepend if any unapplied change is a prepend. + private var unappliedChangeType: ListDataSourceChangeType? { + // Check if DataSource was replaced + if lastDataSourceID != dataSource.id { + return .setItems + } + + let changes = dataSource.pendingChanges + guard appliedCursor < changes.count else { return nil } + + let unapplied = changes[appliedCursor...] + + // Prioritize prepend for scroll position preservation + for change in unapplied { + if case .prepend = change { + return .prepend + } + } + + // Return the first unapplied change type + return unapplied.first.map { change in + switch change { + case .setItems: return .setItems + case .prepend: return .prepend + case .append: return .append + case .update: return .update + case .remove: return .remove + } + } + } + + var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 8) { ForEach(dataSource.items) { 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() + .scrollPositionPreserving(isPrepending: unappliedChangeType == .prepend) + .autoScrollToBottom(isEnabled: autoScrollToBottom) + .onChange(of: dataSource.id) { _, newID in + // DataSource was replaced, reset cursor + lastDataSourceID = newID + appliedCursor = dataSource.pendingChanges.count + } + .onChange(of: dataSource.changeCounter) { _, _ in + // Mark changes as applied after SwiftUI processes the update + DispatchQueue.main.async { + appliedCursor = dataSource.pendingChanges.count } } - .modifier( - _OlderMessagesLoadingModifier( - autoScrollToBottom: autoScrollToBottom, - onLoadOlderMessages: nil, - lastChangeType: lastChangeType - ) - ) + .onAppear { + // Initialize tracking state + lastDataSourceID = dataSource.id + appliedCursor = dataSource.pendingChanges.count + } } } - } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index d159496..6202739 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -154,34 +154,37 @@ public final class TiledView: UIView let newChanges = pendingChanges[appliedCursor...] for change in newChanges { - applyChange(change) + applyChange(change, from: dataSource) } appliedCursor = pendingChanges.count } - private func applyChange(_ change: ListDataSource.Change) { + private func applyChange(_ change: ListDataSource.Change, from dataSource: ListDataSource) { switch change { - case .setItems(let newItems): + case .setItems: tiledLayout.clear() - items = newItems - tiledLayout.appendItems(count: newItems.count, startingIndex: 0) + items = dataSource.items + tiledLayout.appendItems(count: items.count, startingIndex: 0) collectionView.reloadData() - case .prepend(let newItems): + case .prepend(let ids): + let newItems = ids.compactMap { id in dataSource.items.first { $0.id == id } } items.insert(contentsOf: newItems, at: 0) tiledLayout.prependItems(count: newItems.count) collectionView.reloadData() - case .append(let newItems): + case .append(let ids): let startingIndex = items.count + let newItems = ids.compactMap { id in dataSource.items.first { $0.id == id } } items.append(contentsOf: newItems) tiledLayout.appendItems(count: newItems.count, startingIndex: startingIndex) collectionView.reloadData() - case .update(let updatedItems): - for item in updatedItems { - if let index = items.firstIndex(where: { $0.id == item.id }) { - items[index] = item + case .update(let ids): + for id in ids { + if let index = items.firstIndex(where: { $0.id == id }), + let newItem = dataSource.items.first(where: { $0.id == id }) { + items[index] = newItem } } collectionView.reloadData() From 7496be9a65a05648f8f5959bc7de4b1d1e70c7d3 Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 11 Dec 2025 17:40:34 +0900 Subject: [PATCH 14/27] Update --- Dev/MessagingUIDevelopment/Cell.swift | 61 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index 7b6bdc7..e0836c1 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -9,7 +9,7 @@ struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { } func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { - let sampleTexts = [ + let sampleTexts: [String] = [ "こんにちは!", "今日はいい天気ですね。散歩に行きませんか?", "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", @@ -54,8 +54,7 @@ struct ChatBubbleView: View { } Text(message.text) - .font(.system(size: 16)) - + .font(.system(size: 16)) if message.isExpanded { Text("(DataSource expanded)") @@ -72,10 +71,9 @@ struct ChatBubbleView: View { Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") .font(.system(size: 12)) .foregroundStyle(.secondary) -// .fixedSize(horizontal: false, vertical: true) HStack { - ForEach(0..<3) { i in + ForEach(0..<2) { i in Circle() .fill(Color.blue.opacity(0.3)) .frame(width: 30, height: 30) @@ -136,36 +134,49 @@ struct HostingControllerWrapper: UIViewControllerRepresentable { func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: Self.UIViewControllerType, context: Self.Context) -> CGSize? { - let size = uiViewController.sizeThatFits( - in: CGSize( + var size = uiViewController.view.systemLayoutSizeFitting( + CGSize( width: proposal.width ?? UIView.layoutFittingCompressedSize.width, - height: proposal.height ?? UIView.layoutFittingCompressedSize.height - ) + height: UIView.layoutFittingExpandedSize.height + ), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .required ) -// size.height += 80 + +// size.height += 80 + print(size) - return size } } #Preview("UIHostingController") { - - - ZStack { - HostingControllerWrapper( - content: ChatBubbleView( - message: .init( - id: 1, - text: "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。" - ) + + @Previewable @State var size: CGSize = .zero + + VStack { + Text("Size: \(size.width) x \(size.height)") + ZStack { + HostingControllerWrapper( + content: + ZStack { +// Color.clear + ChatBubbleView( + message: .init( + id: 1, + text: "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。" + ) + ) +// .fixedSize(horizontal: false, vertical: true) + } ) - ) - } - .background(.red) - .onGeometryChange(for: CGSize.self, of: \.size) { n in - print("Size changed: \(n)") + } + .background(.red) + .onGeometryChange(for: CGSize.self, of: \.size) { n in + size = n + } } + .padding(.trailing, 100) } From 2d0ec5af3759c9434a682e65d97037ec635cef33 Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 12 Dec 2025 01:30:47 +0900 Subject: [PATCH 15/27] Update --- .claude/settings.local.json | 4 +- Dev/MessagingUIDevelopment/Cell.swift | 34 ++-- Package.resolved | 11 +- Package.swift | 2 - .../Internal/AutoScrollToBottomModifier.swift | 96 ---------- .../ScrollPositionPreservingModifier.swift | 93 ---------- Sources/MessagingUI/MessageList.swift | 166 +++++++++++++----- 7 files changed, 140 insertions(+), 266 deletions(-) delete mode 100644 Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift delete mode 100644 Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d178e28..130ac47 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "mcp__XcodeBuildMCP__discover_projs" + "mcp__XcodeBuildMCP__discover_projs", + "mcp__sosumi__searchAppleDocumentation", + "WebFetch(domain:medium.com)" ] } } diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index e0836c1..57398b3 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -54,26 +54,30 @@ struct ChatBubbleView: View { } Text(message.text) - .font(.system(size: 16)) + .font(.system(size: 16)) + .fixedSize(horizontal: false, vertical: true) if message.isExpanded { Text("(DataSource expanded)") .font(.system(size: 14)) .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) } + if isLocalExpanded { VStack(alignment: .leading, spacing: 10) { Text("Local expanded content") .font(.system(size: 14)) .foregroundStyle(.blue) + .fixedSize(horizontal: false, vertical: true) Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") .font(.system(size: 12)) - .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) HStack { - ForEach(0..<2) { i in + ForEach(0..<3) { i in Circle() .fill(Color.blue.opacity(0.3)) .frame(width: 30, height: 30) @@ -101,7 +105,10 @@ struct ChatBubbleView: View { .padding(.horizontal, 16) .padding(.vertical, 8) .background(Color.init(white: 0.1, opacity: 0.5)) - + .contextMenu { + Button("Hello") { + } + } } } @@ -120,10 +127,12 @@ struct HostingControllerWrapper: UIViewControllerRepresentable { let hostingController = UIHostingController(rootView: content) hostingController.view.backgroundColor = .systemBackground hostingController.sizingOptions = .intrinsicContentSize + hostingController._disableSafeArea = true + hostingController.view.backgroundColor = .clear + hostingController.view + .setContentHuggingPriority(.required, for: .vertical) hostingController.view .setContentCompressionResistancePriority(.required, for: .vertical) - hostingController.view.backgroundColor = .clear - hostingController.safeAreaRegions = [] return hostingController } @@ -137,14 +146,12 @@ struct HostingControllerWrapper: UIViewControllerRepresentable { var size = uiViewController.view.systemLayoutSizeFitting( CGSize( width: proposal.width ?? UIView.layoutFittingCompressedSize.width, - height: UIView.layoutFittingExpandedSize.height + height: 1000 ), withHorizontalFittingPriority: .required, - verticalFittingPriority: .required + verticalFittingPriority: .fittingSizeLevel ) - -// size.height += 80 - + print(size) return size @@ -161,16 +168,12 @@ struct HostingControllerWrapper: UIViewControllerRepresentable { ZStack { HostingControllerWrapper( content: - ZStack { -// Color.clear ChatBubbleView( message: .init( id: 1, text: "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。" ) ) -// .fixedSize(horizontal: false, vertical: true) - } ) } .background(.red) @@ -178,5 +181,4 @@ struct HostingControllerWrapper: UIViewControllerRepresentable { size = n } } - .padding(.trailing, 100) } diff --git a/Package.resolved b/Package.resolved index 7f51284..d6d5508 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bc49af4a8f6bc308be3a7ce3e2127a16e109f8eb4ee9dc721a8479a6b3f8c801", + "originHash" : "8d24494424a8ed4f5da16f114281a1bdc6c1bed126aebdb9164f67a5659e153b", "pins" : [ { "identity" : "swift-collections", @@ -9,15 +9,6 @@ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", "version" : "1.3.0" } - }, - { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", - "state" : { - "revision" : "a08b87f96b41055577721a6e397562b21ad52454", - "version" : "26.0.0" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 98e8d1e..95be862 100644 --- a/Package.swift +++ b/Package.swift @@ -15,14 +15,12 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/siteline/swiftui-introspect", from: "26.0.0"), .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), ], targets: [ .target( name: "MessagingUI", dependencies: [ - .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), .product(name: "DequeModule", package: "swift-collections") ] ), diff --git a/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift b/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift deleted file mode 100644 index ea9c30f..0000000 --- a/Sources/MessagingUI/Internal/AutoScrollToBottomModifier.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// AutoScrollToBottomModifier.swift -// MessagingUI -// -// Created by Hiroshi Kimura on 2025/12/11. -// - -import SwiftUI -import SwiftUIIntrospect -import UIKit - -/// A view modifier that automatically scrolls to the bottom when content is added. -/// -/// When enabled and new content increases the scroll view's content size, -/// this modifier animates the scroll position to the bottom. -struct _AutoScrollToBottomModifier: ViewModifier { - - @StateObject private var controller = _AutoScrollToBottomController() - - private let isEnabled: Binding? - - init(isEnabled: Binding?) { - self.isEnabled = isEnabled - } - - func body(content: Content) -> some View { - Group { - if #available(iOS 18.0, macOS 15.0, *) { - content - .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in - setupContentSizeObservation(scrollView: scrollView) - } - } else { - content - .introspect(.scrollView, on: .iOS(.v17)) { scrollView in - setupContentSizeObservation(scrollView: scrollView) - } - } - } - .onAppear { - controller.isEnabled = isEnabled - } - } - - @MainActor - private func setupContentSizeObservation(scrollView: UIScrollView) { - guard controller.scrollViewRef !== scrollView else { return } - - controller.scrollViewRef = scrollView - controller.contentSizeObservation?.invalidate() - - let controller = self.controller - - 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 and auto-scroll is enabled - if heightDiff > 0, - let isEnabled = controller.isEnabled, - isEnabled.wrappedValue - { - let boundsHeight = scrollView.bounds.height - let bottomOffset = newHeight - boundsHeight - UIView.animate(withDuration: 0.3) { - scrollView.contentOffset.y = max(0, bottomOffset) - } - } - } - } - } -} - -@MainActor -private final class _AutoScrollToBottomController: ObservableObject { - weak var scrollViewRef: UIScrollView? - var contentSizeObservation: NSKeyValueObservation? - var isEnabled: Binding? -} - -// MARK: - View Extension - -extension View { - /// Automatically scrolls to the bottom when content is added. - /// - /// - Parameter isEnabled: Binding that controls whether auto-scroll is active. - func autoScrollToBottom(isEnabled: Binding?) -> some View { - modifier(_AutoScrollToBottomModifier(isEnabled: isEnabled)) - } -} diff --git a/Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift b/Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift deleted file mode 100644 index 46acdb7..0000000 --- a/Sources/MessagingUI/Internal/ScrollPositionPreservingModifier.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// ScrollPositionPreservingModifier.swift -// MessagingUI -// -// Created by Hiroshi Kimura on 2025/12/11. -// - -import SwiftUI -import SwiftUIIntrospect -import UIKit - -/// A view modifier that preserves scroll position when content is prepended. -/// -/// When new content is added at the top of a scroll view, this modifier adjusts -/// the content offset to maintain the user's current scroll position. -struct _ScrollPositionPreservingModifier: ViewModifier { - - @StateObject private var controller = _ScrollPositionPreservingController() - - private let isPrepending: Bool - - init(isPrepending: Bool) { - self.isPrepending = isPrepending - } - - func body(content: Content) -> some View { - Group { - if #available(iOS 18.0, macOS 15.0, *) { - content - .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in - setupContentSizeObservation(scrollView: scrollView) - } - } else { - content - .introspect(.scrollView, on: .iOS(.v17)) { scrollView in - setupContentSizeObservation(scrollView: scrollView) - } - } - } - .onChange(of: isPrepending) { _, newValue in - controller.isPrepending = newValue - } - .onAppear { - controller.isPrepending = isPrepending - } - } - - @MainActor - private func setupContentSizeObservation(scrollView: UIScrollView) { - guard controller.scrollViewRef !== scrollView else { return } - - controller.scrollViewRef = scrollView - controller.contentSizeObservation?.invalidate() - - let controller = self.controller - - 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 and we're prepending - if heightDiff > 0 && controller.isPrepending { - let newOffset = scrollView.contentOffset.y + heightDiff - scrollView.contentOffset.y = newOffset - } - } - } - } -} - -@MainActor -private final class _ScrollPositionPreservingController: ObservableObject { - weak var scrollViewRef: UIScrollView? - var contentSizeObservation: NSKeyValueObservation? - var isPrepending: Bool = false -} - -// MARK: - View Extension - -extension View { - /// Preserves scroll position when content is prepended. - /// - /// - Parameter isPrepending: Whether content is currently being prepended. - func scrollPositionPreserving(isPrepending: Bool) -> some View { - modifier(_ScrollPositionPreservingModifier(isPrepending: isPrepending)) - } -} diff --git a/Sources/MessagingUI/MessageList.swift b/Sources/MessagingUI/MessageList.swift index 7c21415..d12ae48 100644 --- a/Sources/MessagingUI/MessageList.swift +++ b/Sources/MessagingUI/MessageList.swift @@ -6,8 +6,6 @@ // import SwiftUI -import SwiftUIIntrospect -import Combine /// Change type for MessageList to track prepend/append operations. public enum ListDataSourceChangeType: Equatable, Sendable { @@ -82,65 +80,137 @@ private struct _MessageListContent: ViewModifier { + @Binding var isPrepending: Bool + @Binding var anchorMessageID: ID? + let autoScrollToBottom: Binding? + let lastItemID: ID? + let proxy: ScrollViewProxy + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentSize.height + } action: { oldHeight, newHeight in + handleContentSizeChange(oldHeight: oldHeight, newHeight: newHeight) + } + } else { + // iOS 17: No onScrollGeometryChange, use onChange of data as fallback + content + .onChange(of: anchorMessageID) { _, newValue in + // When anchorMessageID is set and then layout happens, + // scroll to anchor on next run loop + if isPrepending, let anchorID = newValue { + DispatchQueue.main.async { + proxy.scrollTo(anchorID, anchor: .top) + isPrepending = false + anchorMessageID = nil + } } } - } - .scrollPositionPreserving(isPrepending: unappliedChangeType == .prepend) - .autoScrollToBottom(isEnabled: autoScrollToBottom) - .onChange(of: dataSource.id) { _, newID in - // DataSource was replaced, reset cursor - lastDataSourceID = newID - appliedCursor = dataSource.pendingChanges.count - } - .onChange(of: dataSource.changeCounter) { _, _ in - // Mark changes as applied after SwiftUI processes the update - DispatchQueue.main.async { - appliedCursor = dataSource.pendingChanges.count + .onChange(of: lastItemID) { _, newValue in + // Auto-scroll to bottom on append + if let autoScrollToBottom, + autoScrollToBottom.wrappedValue, + !isPrepending, + let lastID = newValue { + withAnimation(.easeOut(duration: 0.3)) { + proxy.scrollTo(lastID, anchor: .bottom) + } + } + } + } + } + + private func handleContentSizeChange(oldHeight: CGFloat, newHeight: CGFloat) { + let heightDiff = newHeight - oldHeight + guard heightDiff > 0 else { return } + + if isPrepending, let anchorID = anchorMessageID { + // Prepend: Scroll to anchor message to preserve position + proxy.scrollTo(anchorID, anchor: .top) + isPrepending = false + anchorMessageID = nil + } else if let autoScrollToBottom, + autoScrollToBottom.wrappedValue, + !isPrepending { + // Append: Auto-scroll to bottom + if let lastID = lastItemID { + withAnimation(.easeOut(duration: 0.3)) { + proxy.scrollTo(lastID, anchor: .bottom) } - } - .onAppear { - // Initialize tracking state - lastDataSourceID = dataSource.id - appliedCursor = dataSource.pendingChanges.count } } } From 96ad3574712a37faa01b4632c97110406976bef6 Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 12 Dec 2025 02:08:52 +0900 Subject: [PATCH 16/27] Update --- Dev/MessagingUIDevelopment/ContentView.swift | 18 ++ .../MessagingUIDevelopmentApp.swift | 13 + .../SwiftDataMemoDemo.swift | 284 ++++++++++++++++++ .../TiledViewDemo.swift | 4 +- Sources/MessagingUI/ListDataSource.swift | 141 +++++++++ Sources/MessagingUI/Tiled/TiledView.swift | 47 ++- .../ListDataSourceTests.swift | 186 ++++++++++++ 7 files changed, 680 insertions(+), 13 deletions(-) create mode 100644 Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift create mode 100644 Tests/MessagingUITests/ListDataSourceTests.swift diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 4ee0211..458f017 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -101,6 +101,24 @@ struct ContentView: View { } } } + + Section("SwiftData Integration") { + NavigationLink { + SwiftDataMemoDemo() + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("Memo Stream") + Text("SwiftData + TiledView pagination") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "note.text") + } + } + } } .navigationTitle("MessagingUI") } diff --git a/Dev/MessagingUIDevelopment/MessagingUIDevelopmentApp.swift b/Dev/MessagingUIDevelopment/MessagingUIDevelopmentApp.swift index 9436190..63a44a7 100644 --- a/Dev/MessagingUIDevelopment/MessagingUIDevelopmentApp.swift +++ b/Dev/MessagingUIDevelopment/MessagingUIDevelopmentApp.swift @@ -6,12 +6,25 @@ // import SwiftUI +import SwiftData @main struct MessagingUIDevelopmentApp: App { + + var sharedModelContainer: ModelContainer = { + let schema = Schema([Memo.self]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + var body: some Scene { WindowGroup { ContentView() } + .modelContainer(sharedModelContainer) } } diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift new file mode 100644 index 0000000..23c6fce --- /dev/null +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -0,0 +1,284 @@ +// +// SwiftDataMemoDemo.swift +// MessagingUIDevelopment +// +// Created by Claude on 2025/12/12. +// + +import SwiftUI +import SwiftData +import MessagingUI + +// MARK: - SwiftData Model + +@Model +final class Memo { + var text: String + var createdAt: Date + + init(text: String, createdAt: Date = .now) { + self.text = text + self.createdAt = createdAt + } +} + +// MARK: - MemoItem (Identifiable & Equatable wrapper) + +struct MemoItem: Identifiable, Equatable { + let id: PersistentIdentifier + let text: String + let createdAt: Date + + init(memo: Memo) { + self.id = memo.persistentModelID + self.text = memo.text + self.createdAt = memo.createdAt + } +} + +// MARK: - MemoBubbleView + +struct MemoBubbleView: View { + + let item: MemoItem + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(item.text) + .font(.system(size: 16)) + .fixedSize(horizontal: false, vertical: true) + + Text(Self.dateFormatter.string(from: item.createdAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + + Spacer(minLength: 44) + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } +} + +// MARK: - MemoStore + +@Observable +final class MemoStore { + + private let modelContext: ModelContext + private(set) var dataSource = ListDataSource() + private(set) var hasMore = true + private var oldestLoadedDate: Date? + + private let pageSize = 10 + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func loadInitial() { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + descriptor.fetchLimit = pageSize + + let memos = (try? modelContext.fetch(descriptor)) ?? [] + // 表示は古い→新しいなので reverse + let items = memos.reversed().map(MemoItem.init) + dataSource.setItems(Array(items)) + oldestLoadedDate = memos.last?.createdAt + hasMore = memos.count == pageSize + } + + func loadMore() { + guard let oldestDate = oldestLoadedDate, hasMore else { return } + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.createdAt < oldestDate }, + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + descriptor.fetchLimit = pageSize + + let memos = (try? modelContext.fetch(descriptor)) ?? [] + // prependなので古い順に追加 + let items = memos.reversed().map(MemoItem.init) + dataSource.prepend(Array(items)) + oldestLoadedDate = memos.last?.createdAt + hasMore = memos.count == pageSize + } + + func addMemo(text: String) { + let memo = Memo(text: text) + modelContext.insert(memo) + try? modelContext.save() + dataSource.append([MemoItem(memo: memo)]) + } + + private static let sampleTexts = [ + "Hello!", + "How are you today?", + "I'm working on a new project.", + "SwiftData is really convenient.", + "TiledView works great for chat UIs!", + "This is a longer message to test how the layout handles multi-line content.", + "Short one.", + "Another memo here.", + "Testing pagination...", + "Quick note 📝", + ] + + func addRandomMemo() { + let text = Self.sampleTexts.randomElement() ?? "New memo" + addMemo(text: text) + } + + func addMultipleMemos(count: Int) { + for _ in 0..: Equatable { case setItems case prepend([Item.ID]) case append([Item.ID]) + case insert(at: Int, ids: [Item.ID]) case update([Item.ID]) case remove([Item.ID]) } @@ -87,6 +88,18 @@ public struct ListDataSource: Equatable { changeCounter += 1 } + /// Inserts items at a specific index. + /// Use this for middle insertions (not at beginning or end). + public mutating func insert(_ items: [Item], at index: Int) { + guard !items.isEmpty else { return } + let ids = items.map { $0.id } + for (offset, item) in items.enumerated() { + self._items.insert(item, at: index + offset) + } + pendingChanges.append(.insert(at: index, ids: ids)) + changeCounter += 1 + } + /// Updates existing items by matching their IDs. /// Items that don't exist in the current list are ignored. public mutating func update(_ items: [Item]) { @@ -144,6 +157,134 @@ extension ListDataSource where Item.ID: Hashable { changeCounter += 1 } } + + /// Applies the difference between current items and new items. + /// Automatically detects prepend, append, insert, update, and remove operations. + public mutating func applyDiff(from newItems: [Item]) { + let oldItems = self.items + + // Empty to non-empty: use setItems + if oldItems.isEmpty && !newItems.isEmpty { + setItems(newItems) + return + } + + // Non-empty to empty: remove all + if !oldItems.isEmpty && newItems.isEmpty { + remove(ids: Set(oldItems.map { $0.id })) + return + } + + // Both empty: nothing to do + if oldItems.isEmpty && newItems.isEmpty { + return + } + + // Detect changes using Swift's difference API + let oldIDs = oldItems.map { $0.id } + let newIDs = newItems.map { $0.id } + let diff = newIDs.difference(from: oldIDs) + + // Build indexed insertion and removal lists + var insertions: [(offset: Int, id: Item.ID)] = [] + var removedIDsSet: Set = [] + + for change in diff { + switch change { + case .insert(let offset, let id, _): + insertions.append((offset, id)) + case .remove(_, let id, _): + removedIDsSet.insert(id) + } + } + + // Handle removals first + if !removedIDsSet.isEmpty { + remove(ids: removedIDsSet) + } + + // Classify insertions by position + let newItemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) + let insertedIDsSet = Set(insertions.map { $0.id }) + + // Find prepended items (consecutive from index 0) + var prependedItems: [Item] = [] + for (index, id) in newIDs.enumerated() { + if insertedIDsSet.contains(id), let item = newItemsDict[id] { + if index == prependedItems.count { + prependedItems.append(item) + } else { + break + } + } else { + break + } + } + + // Find appended items (consecutive from the end) + var appendedItems: [Item] = [] + let prependedIDsSet = Set(prependedItems.map { $0.id }) + for (index, id) in newIDs.enumerated().reversed() { + if insertedIDsSet.contains(id) && !prependedIDsSet.contains(id), + let item = newItemsDict[id] { + if index == newIDs.count - 1 - appendedItems.count { + appendedItems.insert(item, at: 0) + } else { + break + } + } else { + break + } + } + + // Find middle insertions + let appendedIDsSet = Set(appendedItems.map { $0.id }) + + // Group consecutive middle insertions + var middleInsertions: [(index: Int, items: [Item])] = [] + for (offset, id) in insertions { + if prependedIDsSet.contains(id) || appendedIDsSet.contains(id) { + continue + } + guard let item = newItemsDict[id] else { continue } + + // Adjust index for prepends already applied + let adjustedIndex = offset - prependedItems.count + + if let lastGroup = middleInsertions.last, + lastGroup.index + lastGroup.items.count == adjustedIndex { + middleInsertions[middleInsertions.count - 1].items.append(item) + } else { + middleInsertions.append((adjustedIndex, [item])) + } + } + + // Apply changes in order + if !prependedItems.isEmpty { + prepend(prependedItems) + } + + for (index, items) in middleInsertions { + insert(items, at: index) + } + + if !appendedItems.isEmpty { + append(appendedItems) + } + + // Detect updates (same ID, different content) + let oldItemsDict = Dictionary(uniqueKeysWithValues: oldItems.map { ($0.id, $0) }) + var updatedItems: [Item] = [] + for newItem in newItems { + if let oldItem = oldItemsDict[newItem.id], oldItem != newItem { + updatedItems.append(newItem) + } + } + + if !updatedItems.isEmpty { + update(updatedItems) + } + } } // MARK: - Backward Compatibility diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 6202739..078ec50 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -47,16 +47,14 @@ public final class TiledViewCell: UICollectionViewCell { verticalFittingPriority: .fittingSizeLevel ) - print("[Cell] preferredLayoutAttributesFitting index=\(layoutAttributes.indexPath.item) original=\(layoutAttributes.frame.size.height) calculated=\(size.height)") - attributes.frame.size.height = size.height return attributes } } -// MARK: - TiledView +// MARK: - _TiledView -public final class TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { +public final class _TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { private var collectionView: UICollectionView! private var tiledLayout: TiledCollectionViewLayout! @@ -71,10 +69,13 @@ public final class TiledView: UIView private var lastDataSourceID: UUID? private var appliedCursor: Int = 0 + /// Prepend trigger state + private var isPrependTriggered: Bool = false + private let prependThreshold: CGFloat = 100 + public typealias DataSource = ListDataSource public var onPrepend: (() -> Void)? - public var onAppend: (() -> Void)? public init( cellBuilder: @escaping (Item) -> Cell @@ -101,6 +102,7 @@ public final class TiledView: UIView collectionView.allowsSelection = true collectionView.dataSource = self collectionView.delegate = self + collectionView.alwaysBounceVertical = true collectionView.register(TiledViewCell.self, forCellWithReuseIdentifier: TiledViewCell.reuseIdentifier) @@ -180,6 +182,14 @@ public final class TiledView: UIView tiledLayout.appendItems(count: newItems.count, startingIndex: startingIndex) collectionView.reloadData() + case .insert(let index, let ids): + let newItems = ids.compactMap { id in dataSource.items.first { $0.id == id } } + for (offset, item) in newItems.enumerated() { + items.insert(item, at: index + offset) + } + // TODO: Update TiledCollectionViewLayout for middle insertions + collectionView.reloadData() + case .update(let ids): for id in ids { if let index = items.firstIndex(where: { $0.id == id }), @@ -219,13 +229,28 @@ public final class TiledView: UIView public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // Override in subclass or use closure if needed } + + // MARK: - UIScrollViewDelegate + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + scrollView.contentInset.top + + if offsetY <= prependThreshold { + if !isPrependTriggered { + isPrependTriggered = true + onPrepend?() + } + } else { + isPrependTriggered = false + } + } } -// MARK: - TiledViewRepresentable +// MARK: - TiledView -public struct TiledViewRepresentable: UIViewRepresentable { +public struct TiledView: UIViewRepresentable { - public typealias UIViewType = TiledView + public typealias UIViewType = _TiledView let dataSource: ListDataSource let cellBuilder: (Item) -> Cell @@ -238,13 +263,13 @@ public struct TiledViewRepresentable self.cellBuilder = cellBuilder } - public func makeUIView(context: Context) -> TiledView { - let view = TiledView(cellBuilder: cellBuilder) + public func makeUIView(context: Context) -> _TiledView { + let view = _TiledView(cellBuilder: cellBuilder) view.applyDataSource(dataSource) return view } - public func updateUIView(_ uiView: TiledView, context: Context) { + public func updateUIView(_ uiView: _TiledView, context: Context) { uiView.applyDataSource(dataSource) } } diff --git a/Tests/MessagingUITests/ListDataSourceTests.swift b/Tests/MessagingUITests/ListDataSourceTests.swift new file mode 100644 index 0000000..8370d21 --- /dev/null +++ b/Tests/MessagingUITests/ListDataSourceTests.swift @@ -0,0 +1,186 @@ +// +// ListDataSourceTests.swift +// MessagingUITests +// +// Created by Hiroshi Kimura on 2025/12/12. +// + +import Testing +@testable import MessagingUI + +struct ListDataSourceTests { + + // MARK: - Test Item + + struct TestItem: Identifiable, Equatable { + let id: Int + var value: String + } + + // MARK: - applyDiff Tests + + @Test + func emptyToNonEmpty() { + var dataSource = ListDataSource() + let newItems = [TestItem(id: 1, value: "A"), TestItem(id: 2, value: "B")] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.last == .setItems) + } + + @Test + func nonEmptyToEmpty() { + var dataSource = ListDataSource(items: [TestItem(id: 1, value: "A")]) + + dataSource.applyDiff(from: []) + + #expect(dataSource.items.isEmpty) + #expect(dataSource.pendingChanges.last == .remove([1])) + } + + @Test + func prependItems() { + var dataSource = ListDataSource(items: [TestItem(id: 2, value: "B")]) + let newItems = [TestItem(id: 1, value: "A"), TestItem(id: 2, value: "B")] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.prepend([1]))) + } + + @Test + func appendItems() { + var dataSource = ListDataSource(items: [TestItem(id: 1, value: "A")]) + let newItems = [TestItem(id: 1, value: "A"), TestItem(id: 2, value: "B")] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.append([2]))) + } + + @Test + func insertInMiddle() { + var dataSource = ListDataSource(items: [ + TestItem(id: 1, value: "A"), + TestItem(id: 3, value: "C") + ]) + let newItems = [ + TestItem(id: 1, value: "A"), + TestItem(id: 2, value: "B"), + TestItem(id: 3, value: "C") + ] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.insert(at: 1, ids: [2]))) + } + + @Test + func updateItems() { + var dataSource = ListDataSource(items: [TestItem(id: 1, value: "A")]) + let newItems = [TestItem(id: 1, value: "A-Updated")] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.update([1]))) + } + + @Test + func removeItems() { + var dataSource = ListDataSource(items: [ + TestItem(id: 1, value: "A"), + TestItem(id: 2, value: "B") + ]) + let newItems = [TestItem(id: 1, value: "A")] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.remove([2]))) + } + + @Test + func complexOperation() { + // prepend + remove + update + var dataSource = ListDataSource(items: [ + TestItem(id: 2, value: "B"), + TestItem(id: 3, value: "C"), + TestItem(id: 4, value: "D") + ]) + let newItems = [ + TestItem(id: 1, value: "A"), // prepend + TestItem(id: 2, value: "B-Updated"), // update + TestItem(id: 3, value: "C") // unchanged + // id: 4 removed + ] + + dataSource.applyDiff(from: newItems) + + #expect(dataSource.items == newItems) + #expect(dataSource.pendingChanges.contains(.remove([4]))) + #expect(dataSource.pendingChanges.contains(.prepend([1]))) + #expect(dataSource.pendingChanges.contains(.update([2]))) + } + + @Test + func noChanges() { + let items = [TestItem(id: 1, value: "A"), TestItem(id: 2, value: "B")] + var dataSource = ListDataSource(items: items) + let initialChangeCount = dataSource.pendingChanges.count + + dataSource.applyDiff(from: items) + + // No new changes should be added + #expect(dataSource.pendingChanges.count == initialChangeCount) + } + + // MARK: - insert mutation method tests + + @Test + func insertMutationMethod() { + var dataSource = ListDataSource(items: [ + TestItem(id: 1, value: "A"), + TestItem(id: 3, value: "C") + ]) + + dataSource.insert([TestItem(id: 2, value: "B")], at: 1) + + #expect(dataSource.items.count == 3) + #expect(dataSource.items[1].id == 2) + #expect(dataSource.pendingChanges.last == .insert(at: 1, ids: [2])) + } + + @Test + func insertMultipleItems() { + var dataSource = ListDataSource(items: [ + TestItem(id: 1, value: "A"), + TestItem(id: 4, value: "D") + ]) + + dataSource.insert([ + TestItem(id: 2, value: "B"), + TestItem(id: 3, value: "C") + ], at: 1) + + #expect(dataSource.items.count == 4) + #expect(dataSource.items.map { $0.id } == [1, 2, 3, 4]) + #expect(dataSource.pendingChanges.last == .insert(at: 1, ids: [2, 3])) + } + + @Test + func insertEmptyArray() { + var dataSource = ListDataSource(items: [TestItem(id: 1, value: "A")]) + let initialChangeCount = dataSource.pendingChanges.count + + dataSource.insert([], at: 0) + + // No change should be added for empty insert + #expect(dataSource.pendingChanges.count == initialChangeCount) + } +} From 0393fad2568a44b68d3c32ec735722051430c02a Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 12 Dec 2025 04:12:23 +0900 Subject: [PATCH 17/27] Update --- .../ApplyDiffDemo.swift | 218 ++++++++++++++++++ Dev/MessagingUIDevelopment/ContentView.swift | 17 ++ .../SwiftDataMemoDemo.swift | 3 + Sources/MessagingUI/ListDataSource.swift | 29 ++- .../Tiled/TiledCollectionViewLayout.swift | 33 +++ Sources/MessagingUI/Tiled/TiledView.swift | 40 +++- 6 files changed, 330 insertions(+), 10 deletions(-) create mode 100644 Dev/MessagingUIDevelopment/ApplyDiffDemo.swift diff --git a/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift new file mode 100644 index 0000000..ecd504f --- /dev/null +++ b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift @@ -0,0 +1,218 @@ +// +// ApplyDiffDemo.swift +// MessagingUIDevelopment +// +// Created by Hiroshi Kimura on 2025/12/12. +// + +import SwiftUI +import MessagingUI + +// MARK: - ApplyDiff Demo + +/// Demonstrates the `applyDiff(from:)` method which automatically detects +/// prepend, append, insert, update, and remove operations from array differences. +struct BookApplyDiffDemo: View { + + @State private var dataSource = ListDataSource() + + /// Source of truth - the "server" data + @State private var serverItems: [ChatMessage] = [] + + /// Next ID for new items + @State private var nextId = 0 + + /// Log of operations performed + @State private var operationLog: [String] = [] + + /// Previous change counter to detect new changes + @State private var previousChangeCounter = 0 + + var body: some View { + VStack(spacing: 0) { + // Control Panel + VStack(spacing: 12) { + Text("applyDiff Demo") + .font(.headline) + + Text("Modify the 'server' array, then applyDiff auto-detects changes") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + // Row 1: Basic operations + HStack { + Button("Prepend") { + let newItem = ChatMessage(id: nextId, text: "Prepended #\(nextId)") + nextId += 1 + serverItems.insert(newItem, at: 0) + applyAndLog("prepend(1)") + } + .buttonStyle(.bordered) + + Button("Append") { + let newItem = ChatMessage(id: nextId, text: "Appended #\(nextId)") + nextId += 1 + serverItems.append(newItem) + applyAndLog("append(1)") + } + .buttonStyle(.bordered) + + Button("Insert Mid") { + guard serverItems.count >= 2 else { + serverItems.append(ChatMessage(id: nextId, text: "First #\(nextId)")) + nextId += 1 + applyAndLog("setItems") + return + } + let midIndex = serverItems.count / 2 + let newItem = ChatMessage(id: nextId, text: "Inserted #\(nextId)") + nextId += 1 + serverItems.insert(newItem, at: midIndex) + applyAndLog("insert@\(midIndex)(1)") + } + .buttonStyle(.bordered) + } + + // Row 2: Update / Remove + HStack { + Button("Update First") { + guard !serverItems.isEmpty else { return } + serverItems[0].text = "Updated! \(Date().formatted(date: .omitted, time: .standard))" + applyAndLog("update(1)") + } + .buttonStyle(.bordered) + + Button("Remove Last") { + guard !serverItems.isEmpty else { return } + serverItems.removeLast() + applyAndLog("remove(1)") + } + .buttonStyle(.bordered) + + Button("Shuffle") { + serverItems.shuffle() + applyAndLog("shuffle→setItems") + } + .buttonStyle(.bordered) + } + + // Row 3: Complex operations + HStack { + Button("Prepend+Update+Remove") { + guard serverItems.count >= 2 else { + // Initialize with some items + serverItems = [ + ChatMessage(id: nextId, text: "Item A"), + ChatMessage(id: nextId + 1, text: "Item B"), + ChatMessage(id: nextId + 2, text: "Item C"), + ] + nextId += 3 + applyAndLog("setItems(3)") + return + } + + // Prepend new item + let newItem = ChatMessage(id: nextId, text: "New Prepended #\(nextId)") + nextId += 1 + serverItems.insert(newItem, at: 0) + + // Update second item (was first before prepend) + if serverItems.count > 1 { + serverItems[1].text = "Updated!" + } + + // Remove last item + serverItems.removeLast() + + applyAndLog("remove+prepend+update") + } + .buttonStyle(.bordered) + .tint(.orange) + + Button("Reset") { + serverItems = [] + nextId = 0 + operationLog = [] + previousChangeCounter = 0 + dataSource = ListDataSource() + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + + // Stats + HStack { + Text("Items: \(serverItems.count)") + Spacer() + Text("ChangeCounter: \(dataSource.changeCounter)") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color(.systemBackground)) + + Divider() + + // Operation Log + VStack(alignment: .leading, spacing: 4) { + Text("Operation Log (expected changes):") + .font(.caption.bold()) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(operationLog.suffix(10), id: \.self) { log in + Text(log) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(logColor(for: log).opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground)) + + Divider() + + // List View + TiledView( + dataSource: dataSource, + cellBuilder: { message in + ChatBubbleView(message: message) + } + ) + } + } + + private func applyAndLog(_ expectedChange: String) { + var updatedDataSource = dataSource + updatedDataSource.applyDiff(from: serverItems) + + // Check if change counter increased + let newCounter = updatedDataSource.changeCounter + if newCounter > previousChangeCounter { + operationLog.append(expectedChange) + previousChangeCounter = newCounter + } + + dataSource = updatedDataSource + } + + private func logColor(for log: String) -> Color { + if log.contains("prepend") { return .blue } + if log.contains("append") { return .green } + if log.contains("insert") { return .purple } + if log.contains("update") { return .orange } + if log.contains("remove") { return .red } + if log.contains("setItems") || log.contains("shuffle") { return .gray } + return .primary + } +} + +#Preview("ApplyDiff Demo") { + BookApplyDiffDemo() +} diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 458f017..7c43513 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -81,6 +81,23 @@ struct ContentView: View { Image(systemName: "list.bullet") } } + + NavigationLink { + BookApplyDiffDemo() + .navigationTitle("applyDiff Demo") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label { + VStack(alignment: .leading) { + Text("applyDiff Demo") + Text("Auto-detect array changes") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.triangle.2.circlepath") + } + } } Section("Other Examples") { diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift index 23c6fce..ab831dd 100644 --- a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -221,6 +221,9 @@ struct SwiftDataMemoDemo: View { TiledView( dataSource: store.dataSource, + onPrepend: { + store.loadMore() + }, cellBuilder: { item in MemoBubbleView(item: item) } diff --git a/Sources/MessagingUI/ListDataSource.swift b/Sources/MessagingUI/ListDataSource.swift index 0ec830c..f30a777 100644 --- a/Sources/MessagingUI/ListDataSource.swift +++ b/Sources/MessagingUI/ListDataSource.swift @@ -143,9 +143,7 @@ public struct ListDataSource: Equatable { } } -// MARK: - Item.ID Hashable conformance for Set operations - -extension ListDataSource where Item.ID: Hashable { +extension ListDataSource { /// Removes items with the specified IDs (optimized for Hashable IDs). public mutating func remove(ids: Set) { @@ -163,20 +161,28 @@ extension ListDataSource where Item.ID: Hashable { public mutating func applyDiff(from newItems: [Item]) { let oldItems = self.items + print("[applyDiff] START") + print("[applyDiff] oldItems.count: \(oldItems.count), newItems.count: \(newItems.count)") + print("[applyDiff] oldIDs: \(oldItems.map { $0.id })") + print("[applyDiff] newIDs: \(newItems.map { $0.id })") + // Empty to non-empty: use setItems if oldItems.isEmpty && !newItems.isEmpty { + print("[applyDiff] -> setItems (empty to non-empty)") setItems(newItems) return } // Non-empty to empty: remove all if !oldItems.isEmpty && newItems.isEmpty { + print("[applyDiff] -> remove all (non-empty to empty)") remove(ids: Set(oldItems.map { $0.id })) return } // Both empty: nothing to do if oldItems.isEmpty && newItems.isEmpty { + print("[applyDiff] -> no-op (both empty)") return } @@ -184,6 +190,7 @@ extension ListDataSource where Item.ID: Hashable { let oldIDs = oldItems.map { $0.id } let newIDs = newItems.map { $0.id } let diff = newIDs.difference(from: oldIDs) + print("[applyDiff] diff: \(diff)") // Build indexed insertion and removal lists var insertions: [(offset: Int, id: Item.ID)] = [] @@ -198,14 +205,19 @@ extension ListDataSource where Item.ID: Hashable { } } + print("[applyDiff] insertions: \(insertions)") + print("[applyDiff] removedIDsSet: \(removedIDsSet)") + // Handle removals first if !removedIDsSet.isEmpty { + print("[applyDiff] -> calling remove(ids:)") remove(ids: removedIDsSet) } // Classify insertions by position let newItemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) let insertedIDsSet = Set(insertions.map { $0.id }) + print("[applyDiff] insertedIDsSet: \(insertedIDsSet)") // Find prepended items (consecutive from index 0) var prependedItems: [Item] = [] @@ -237,6 +249,9 @@ extension ListDataSource where Item.ID: Hashable { } } + print("[applyDiff] prependedItems: \(prependedItems.map { $0.id })") + print("[applyDiff] appendedItems: \(appendedItems.map { $0.id })") + // Find middle insertions let appendedIDsSet = Set(appendedItems.map { $0.id }) @@ -259,16 +274,21 @@ extension ListDataSource where Item.ID: Hashable { } } + print("[applyDiff] middleInsertions: \(middleInsertions.map { (index: $0.index, ids: $0.items.map { $0.id }) })") + // Apply changes in order if !prependedItems.isEmpty { + print("[applyDiff] -> calling prepend(\(prependedItems.map { $0.id }))") prepend(prependedItems) } for (index, items) in middleInsertions { + print("[applyDiff] -> calling insert(\(items.map { $0.id }), at: \(index))") insert(items, at: index) } if !appendedItems.isEmpty { + print("[applyDiff] -> calling append(\(appendedItems.map { $0.id }))") append(appendedItems) } @@ -282,8 +302,11 @@ extension ListDataSource where Item.ID: Hashable { } if !updatedItems.isEmpty { + print("[applyDiff] -> calling update(\(updatedItems.map { $0.id }))") update(updatedItems) } + + print("[applyDiff] END - changeCounter: \(changeCounter)") } } diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 7b53492..c05e054 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -181,6 +181,39 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { } } + public func insertItems(count: Int, at index: Int) { + let width = collectionView?.bounds.width ?? 0 + + // Calculate the starting Y position for inserted items + let startY: CGFloat + if index < itemYPositions.count { + startY = itemYPositions[index] + } else if let lastY = itemYPositions.last, let lastHeight = itemHeights.last { + startY = lastY + lastHeight + } else { + startY = anchorY + } + + // Calculate heights and insert + var currentY = startY + var totalInsertedHeight: CGFloat = 0 + + for i in 0.. (top: CGFloat, bottom: CGFloat)? { guard let firstY = itemYPositions.first, let lastY = itemYPositions.last, diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 078ec50..dac5041 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -72,15 +72,18 @@ public final class _TiledView: UIVie /// Prepend trigger state private var isPrependTriggered: Bool = false private let prependThreshold: CGFloat = 100 + private var prependTask: Task? public typealias DataSource = ListDataSource - public var onPrepend: (() -> Void)? + public let onPrepend: (@MainActor () async throws -> Void)? public init( - cellBuilder: @escaping (Item) -> Cell + cellBuilder: @escaping (Item) -> Cell, + onPrepend: (@MainActor () async throws -> Void)? = nil ) { self.cellBuilder = cellBuilder + self.onPrepend = onPrepend super.init(frame: .zero) setupCollectionView() } @@ -142,8 +145,15 @@ public final class _TiledView: UIVie /// Applies changes from a ListDataSource. /// Uses cursor tracking to apply only new changes since last application. public func applyDataSource(_ dataSource: ListDataSource) { + print("[TiledView] applyDataSource called") + print("[TiledView] lastDataSourceID: \(String(describing: lastDataSourceID))") + print("[TiledView] dataSource.id: \(dataSource.id)") + print("[TiledView] appliedCursor: \(appliedCursor)") + print("[TiledView] pendingChanges.count: \(dataSource.pendingChanges.count)") + // Check if this is a new DataSource instance if lastDataSourceID != dataSource.id { + print("[TiledView] -> NEW DataSource detected, resetting") lastDataSourceID = dataSource.id appliedCursor = 0 tiledLayout.clear() @@ -152,10 +162,15 @@ public final class _TiledView: UIVie // Apply only changes after the cursor let pendingChanges = dataSource.pendingChanges - guard appliedCursor < pendingChanges.count else { return } + guard appliedCursor < pendingChanges.count else { + print("[TiledView] -> No new changes to apply") + return + } let newChanges = pendingChanges[appliedCursor...] + print("[TiledView] -> Applying \(newChanges.count) changes") for change in newChanges { + print("[TiledView] -> Applying change: \(change)") applyChange(change, from: dataSource) } appliedCursor = pendingChanges.count @@ -183,11 +198,16 @@ public final class _TiledView: UIVie collectionView.reloadData() case .insert(let index, let ids): + print("[TiledView.insert] index: \(index), ids: \(ids)") + print("[TiledView.insert] dataSource.items: \(dataSource.items.map { $0.id })") + print("[TiledView.insert] self.items BEFORE: \(items.map { $0.id })") let newItems = ids.compactMap { id in dataSource.items.first { $0.id == id } } + print("[TiledView.insert] newItems found: \(newItems.map { $0.id })") for (offset, item) in newItems.enumerated() { items.insert(item, at: index + offset) } - // TODO: Update TiledCollectionViewLayout for middle insertions + print("[TiledView.insert] self.items AFTER: \(items.map { $0.id })") + tiledLayout.insertItems(count: newItems.count, at: index) collectionView.reloadData() case .update(let ids): @@ -236,9 +256,12 @@ public final class _TiledView: UIVie let offsetY = scrollView.contentOffset.y + scrollView.contentInset.top if offsetY <= prependThreshold { - if !isPrependTriggered { + if !isPrependTriggered && prependTask == nil { isPrependTriggered = true - onPrepend?() + prependTask = Task { @MainActor [weak self] in + defer { self?.prependTask = nil } + try? await self?.onPrepend?() + } } } else { isPrependTriggered = false @@ -254,17 +277,20 @@ public struct TiledView: UIViewRepre let dataSource: ListDataSource let cellBuilder: (Item) -> Cell + let onPrepend: (() async throws -> Void)? public init( dataSource: ListDataSource, + onPrepend: (() async throws -> Void)? = nil, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self.dataSource = dataSource + self.onPrepend = onPrepend self.cellBuilder = cellBuilder } public func makeUIView(context: Context) -> _TiledView { - let view = _TiledView(cellBuilder: cellBuilder) + let view = _TiledView(cellBuilder: cellBuilder, onPrepend: onPrepend) view.applyDataSource(dataSource) return view } From abf54ab012a6f4ac15e9e6911258a51b26b0f198 Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 12 Dec 2025 04:25:04 +0900 Subject: [PATCH 18/27] Update --- .../SwiftDataMemoDemo.swift | 86 +++++++++---------- Sources/MessagingUI/ListDataSource.swift | 25 ------ .../Tiled/TiledCollectionViewLayout.swift | 6 +- Sources/MessagingUI/Tiled/TiledView.swift | 19 +--- 4 files changed, 45 insertions(+), 91 deletions(-) diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift index ab831dd..dc89681 100644 --- a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -73,7 +73,7 @@ struct MemoBubbleView: View { } } -// MARK: - MemoStore +// MARK: - MemoStore (using applyDiff) @Observable final class MemoStore { @@ -81,50 +81,58 @@ final class MemoStore { private let modelContext: ModelContext private(set) var dataSource = ListDataSource() private(set) var hasMore = true - private var oldestLoadedDate: Date? + /// 現在ロード済みの件数(ページネーション用) + private var loadedCount = 0 private let pageSize = 10 init(modelContext: ModelContext) { self.modelContext = modelContext } + /// 初期ロード: 最新10件を取得 func loadInitial() { - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.createdAt, order: .reverse)] - ) - descriptor.fetchLimit = pageSize - - let memos = (try? modelContext.fetch(descriptor)) ?? [] - // 表示は古い→新しいなので reverse - let items = memos.reversed().map(MemoItem.init) - dataSource.setItems(Array(items)) - oldestLoadedDate = memos.last?.createdAt - hasMore = memos.count == pageSize + loadedCount = pageSize + refreshFromDatabase() } + /// 過去のメモをロード: 取得件数を増やして再フェッチ func loadMore() { - guard let oldestDate = oldestLoadedDate, hasMore else { return } + guard hasMore else { return } + loadedCount += pageSize + refreshFromDatabase() + } + + /// SwiftDataから取得してapplyDiffで差分適用 + private func refreshFromDatabase() { + // 全件数を取得してoffsetを計算 + let totalCount = (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 + let offset = max(0, totalCount - loadedCount) var descriptor = FetchDescriptor( - predicate: #Predicate { $0.createdAt < oldestDate }, - sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + sortBy: [SortDescriptor(\.createdAt, order: .forward)] // 古い→新しい順 ) - descriptor.fetchLimit = pageSize + descriptor.fetchOffset = offset + descriptor.fetchLimit = loadedCount let memos = (try? modelContext.fetch(descriptor)) ?? [] - // prependなので古い順に追加 - let items = memos.reversed().map(MemoItem.init) - dataSource.prepend(Array(items)) - oldestLoadedDate = memos.last?.createdAt - hasMore = memos.count == pageSize + let items = memos.map(MemoItem.init) + + // applyDiffで自動的に差分を検出・適用 + dataSource.applyDiff(from: items) + + hasMore = offset > 0 } + /// 新規メモ追加後にリフレッシュ func addMemo(text: String) { let memo = Memo(text: text) modelContext.insert(memo) try? modelContext.save() - dataSource.append([MemoItem(memo: memo)]) + + // 追加後は件数を1つ増やしてリフレッシュ + loadedCount += 1 + refreshFromDatabase() } private static let sampleTexts = [ @@ -150,9 +158,12 @@ final class MemoStore { let text = Self.sampleTexts.randomElement() ?? "New memo" let memo = Memo(text: text) modelContext.insert(memo) - dataSource.append([MemoItem(memo: memo)]) } try? modelContext.save() + + // 追加した分だけ件数を増やしてリフレッシュ + loadedCount += count + refreshFromDatabase() } } @@ -209,26 +220,15 @@ struct SwiftDataMemoDemo: View { // Memo list if let store { - VStack(spacing: 0) { - // Load More button - if store.hasMore { - Button("Load More") { - store.loadMore() - } - .buttonStyle(.bordered) - .padding(.vertical, 8) + TiledView( + dataSource: store.dataSource, + onPrepend: { + store.loadMore() + }, + cellBuilder: { item in + MemoBubbleView(item: item) } - - TiledView( - dataSource: store.dataSource, - onPrepend: { - store.loadMore() - }, - cellBuilder: { item in - MemoBubbleView(item: item) - } - ) - } + ) } else { Spacer() ProgressView() diff --git a/Sources/MessagingUI/ListDataSource.swift b/Sources/MessagingUI/ListDataSource.swift index f30a777..807c3e3 100644 --- a/Sources/MessagingUI/ListDataSource.swift +++ b/Sources/MessagingUI/ListDataSource.swift @@ -161,28 +161,20 @@ extension ListDataSource { public mutating func applyDiff(from newItems: [Item]) { let oldItems = self.items - print("[applyDiff] START") - print("[applyDiff] oldItems.count: \(oldItems.count), newItems.count: \(newItems.count)") - print("[applyDiff] oldIDs: \(oldItems.map { $0.id })") - print("[applyDiff] newIDs: \(newItems.map { $0.id })") - // Empty to non-empty: use setItems if oldItems.isEmpty && !newItems.isEmpty { - print("[applyDiff] -> setItems (empty to non-empty)") setItems(newItems) return } // Non-empty to empty: remove all if !oldItems.isEmpty && newItems.isEmpty { - print("[applyDiff] -> remove all (non-empty to empty)") remove(ids: Set(oldItems.map { $0.id })) return } // Both empty: nothing to do if oldItems.isEmpty && newItems.isEmpty { - print("[applyDiff] -> no-op (both empty)") return } @@ -190,7 +182,6 @@ extension ListDataSource { let oldIDs = oldItems.map { $0.id } let newIDs = newItems.map { $0.id } let diff = newIDs.difference(from: oldIDs) - print("[applyDiff] diff: \(diff)") // Build indexed insertion and removal lists var insertions: [(offset: Int, id: Item.ID)] = [] @@ -205,19 +196,14 @@ extension ListDataSource { } } - print("[applyDiff] insertions: \(insertions)") - print("[applyDiff] removedIDsSet: \(removedIDsSet)") - // Handle removals first if !removedIDsSet.isEmpty { - print("[applyDiff] -> calling remove(ids:)") remove(ids: removedIDsSet) } // Classify insertions by position let newItemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) let insertedIDsSet = Set(insertions.map { $0.id }) - print("[applyDiff] insertedIDsSet: \(insertedIDsSet)") // Find prepended items (consecutive from index 0) var prependedItems: [Item] = [] @@ -249,9 +235,6 @@ extension ListDataSource { } } - print("[applyDiff] prependedItems: \(prependedItems.map { $0.id })") - print("[applyDiff] appendedItems: \(appendedItems.map { $0.id })") - // Find middle insertions let appendedIDsSet = Set(appendedItems.map { $0.id }) @@ -274,21 +257,16 @@ extension ListDataSource { } } - print("[applyDiff] middleInsertions: \(middleInsertions.map { (index: $0.index, ids: $0.items.map { $0.id }) })") - // Apply changes in order if !prependedItems.isEmpty { - print("[applyDiff] -> calling prepend(\(prependedItems.map { $0.id }))") prepend(prependedItems) } for (index, items) in middleInsertions { - print("[applyDiff] -> calling insert(\(items.map { $0.id }), at: \(index))") insert(items, at: index) } if !appendedItems.isEmpty { - print("[applyDiff] -> calling append(\(appendedItems.map { $0.id }))") append(appendedItems) } @@ -302,11 +280,8 @@ extension ListDataSource { } if !updatedItems.isEmpty { - print("[applyDiff] -> calling update(\(updatedItems.map { $0.id }))") update(updatedItems) } - - print("[applyDiff] END - changeCounter: \(changeCounter)") } } diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index c05e054..5c6b3d9 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -123,9 +123,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes ) -> Bool { - let shouldInvalidate = preferredAttributes.frame.size.height != originalAttributes.frame.size.height - print("[Layout] shouldInvalidateLayout index=\(preferredAttributes.indexPath.item) preferred=\(preferredAttributes.frame.size.height) original=\(originalAttributes.frame.size.height) -> \(shouldInvalidate)") - return shouldInvalidate + preferredAttributes.frame.size.height != originalAttributes.frame.size.height } public override func invalidationContext( @@ -140,8 +138,6 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { let index = preferredAttributes.indexPath.item let newHeight = preferredAttributes.frame.size.height - print("[Layout] invalidationContext index=\(index) newHeight=\(newHeight) currentStoredHeight=\(index < itemHeights.count ? itemHeights[index] : -1)") - if index < itemHeights.count { updateItemHeight(at: index, newHeight: newHeight) } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index dac5041..9397531 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -145,15 +145,8 @@ public final class _TiledView: UIVie /// Applies changes from a ListDataSource. /// Uses cursor tracking to apply only new changes since last application. public func applyDataSource(_ dataSource: ListDataSource) { - print("[TiledView] applyDataSource called") - print("[TiledView] lastDataSourceID: \(String(describing: lastDataSourceID))") - print("[TiledView] dataSource.id: \(dataSource.id)") - print("[TiledView] appliedCursor: \(appliedCursor)") - print("[TiledView] pendingChanges.count: \(dataSource.pendingChanges.count)") - // Check if this is a new DataSource instance if lastDataSourceID != dataSource.id { - print("[TiledView] -> NEW DataSource detected, resetting") lastDataSourceID = dataSource.id appliedCursor = 0 tiledLayout.clear() @@ -162,15 +155,10 @@ public final class _TiledView: UIVie // Apply only changes after the cursor let pendingChanges = dataSource.pendingChanges - guard appliedCursor < pendingChanges.count else { - print("[TiledView] -> No new changes to apply") - return - } + guard appliedCursor < pendingChanges.count else { return } let newChanges = pendingChanges[appliedCursor...] - print("[TiledView] -> Applying \(newChanges.count) changes") for change in newChanges { - print("[TiledView] -> Applying change: \(change)") applyChange(change, from: dataSource) } appliedCursor = pendingChanges.count @@ -198,15 +186,10 @@ public final class _TiledView: UIVie collectionView.reloadData() case .insert(let index, let ids): - print("[TiledView.insert] index: \(index), ids: \(ids)") - print("[TiledView.insert] dataSource.items: \(dataSource.items.map { $0.id })") - print("[TiledView.insert] self.items BEFORE: \(items.map { $0.id })") let newItems = ids.compactMap { id in dataSource.items.first { $0.id == id } } - print("[TiledView.insert] newItems found: \(newItems.map { $0.id })") for (offset, item) in newItems.enumerated() { items.insert(item, at: index + offset) } - print("[TiledView.insert] self.items AFTER: \(items.map { $0.id })") tiledLayout.insertItems(count: newItems.count, at: index) collectionView.reloadData() From e3d526dcfe24a8312140acbd5aefa82dd8774d7f Mon Sep 17 00:00:00 2001 From: Muukii Date: Fri, 12 Dec 2025 13:22:54 +0900 Subject: [PATCH 19/27] Update --- Dev/MessagingUIDevelopment/BookChat.swift | 133 ----------- Dev/MessagingUIDevelopment/ContentView.swift | 74 +----- .../TiledViewDemo.swift | 121 ---------- Sources/MessagingUI/MessageList.swift | 217 ------------------ 4 files changed, 1 insertion(+), 544 deletions(-) delete mode 100644 Dev/MessagingUIDevelopment/BookChat.swift delete mode 100644 Sources/MessagingUI/MessageList.swift diff --git a/Dev/MessagingUIDevelopment/BookChat.swift b/Dev/MessagingUIDevelopment/BookChat.swift deleted file mode 100644 index a3af365..0000000 --- a/Dev/MessagingUIDevelopment/BookChat.swift +++ /dev/null @@ -1,133 +0,0 @@ -import MessagingUI -import Foundation -import SwiftUI - -// MARK: - Previews - -private enum MessageSender { - case me - case other -} - -private struct PreviewMessage: Identifiable, Equatable, Hashable { - 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 dataSource = ListDataSource(items: [ - 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("Use buttons to add messages") - .font(.caption) - .foregroundStyle(.secondary) - } - - MessageList( - dataSource: dataSource, - autoScrollToBottom: $autoScrollToBottom - ) { 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("Prepend 5") { - 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) - } - dataSource.prepend(newMessages) - } - .buttonStyle(.bordered) - - Button("Append 5") { - 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) - } - dataSource.append(newMessages) - } - .buttonStyle(.borderedProminent) - - Button("Clear All", role: .destructive) { - dataSource.setItems([]) - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding() - } -} - -#Preview("Interactive Preview") { - MessageListPreviewContainer() -} - -#Preview("Simple Preview") { - @Previewable @State var dataSource = ListDataSource(items: [ - 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), - ]) - - MessageList(dataSource: dataSource) { 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/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 7c43513..7838dcd 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -11,43 +11,7 @@ struct ContentView: View { var body: some View { NavigationStack { List { - Section("List Implementation Comparison") { - NavigationLink { - BookSideBySideComparison() - .navigationTitle("Side by Side") - .navigationBarTitleDisplayMode(.inline) - } label: { - Label { - VStack(alignment: .leading) { - Text("Side by Side") - Text("Same DataSource, split view") - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "rectangle.split.2x1") - } - } - - NavigationLink { - BookListComparison() - .navigationTitle("Tab Comparison") - .navigationBarTitleDisplayMode(.inline) - } label: { - Label { - VStack(alignment: .leading) { - Text("Tab Comparison") - Text("Separate DataSource, tab switch") - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "arrow.left.arrow.right") - } - } - } - - Section("Individual Demos") { + Section("Demos") { NavigationLink { BookTiledView() .navigationTitle("TiledView") @@ -65,23 +29,6 @@ struct ContentView: View { } } - NavigationLink { - BookMessageList() - .navigationTitle("MessageList") - .navigationBarTitleDisplayMode(.inline) - } label: { - Label { - VStack(alignment: .leading) { - Text("MessageList") - Text("LazyVStack based") - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "list.bullet") - } - } - NavigationLink { BookApplyDiffDemo() .navigationTitle("applyDiff Demo") @@ -100,25 +47,6 @@ struct ContentView: View { } } - Section("Other Examples") { - NavigationLink { - MessageListPreviewContainer() - .navigationTitle("Chat Style") - .navigationBarTitleDisplayMode(.inline) - } label: { - Label { - VStack(alignment: .leading) { - Text("Chat Style Demo") - Text("Simple chat bubbles") - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "bubble.left.and.bubble.right") - } - } - } - Section("SwiftData Integration") { NavigationLink { SwiftDataMemoDemo() diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 62cdfd1..164a20d 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -143,127 +143,6 @@ struct BookTiledView: View { } } -// MARK: - MessageList Demo (LazyVStack) - -struct BookMessageList: View { - - @State private var dataSource: ListDataSource - @State private var nextPrependId = -1 - @State private var nextAppendId = 0 - - init() { - _dataSource = State(initialValue: ListDataSource()) - } - - var body: some View { - VStack(spacing: 0) { - ListDemoControlPanel( - dataSource: $dataSource, - nextPrependId: $nextPrependId, - nextAppendId: $nextAppendId - ) - .padding() - .background(Color(.systemBackground)) - - MessageList( - dataSource: dataSource - ) { message in - ChatBubbleView(message: message) - } - } - } -} - -// MARK: - Comparison Demo (TabView) - -struct BookListComparison: View { - - var body: some View { - TabView { - BookTiledView() - .tabItem { - Label("TiledView", systemImage: "square.grid.2x2") - } - - BookMessageList() - .tabItem { - Label("MessageList", systemImage: "list.bullet") - } - } - } -} - -// MARK: - Side-by-Side Comparison (Same DataSource) - -struct BookSideBySideComparison: View { - - @State private var dataSource: ListDataSource - @State private var nextPrependId = -1 - @State private var nextAppendId = 0 - - init() { - _dataSource = State(initialValue: ListDataSource()) - } - - var body: some View { - VStack(spacing: 0) { - // Shared Control Panel - ListDemoControlPanel( - dataSource: $dataSource, - nextPrependId: $nextPrependId, - nextAppendId: $nextAppendId - ) - .padding() - .background(Color(.systemBackground)) - - // Side-by-side views - HStack(spacing: 1) { - // Left: TiledView (UICollectionView) - VStack(spacing: 0) { - Text("TiledView") - .font(.caption.bold()) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - .background(Color.blue.opacity(0.2)) - - TiledView( - dataSource: dataSource, - cellBuilder: { message in - ChatBubbleView(message: message) - } - ) - } - - // Right: MessageList (LazyVStack) - VStack(spacing: 0) { - Text("MessageList") - .font(.caption.bold()) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - .background(Color.green.opacity(0.2)) - - MessageList(dataSource: dataSource) { message in - ChatBubbleView(message: message) - } - } - } - .background(Color(.separator)) - } - } -} - -#Preview("Side by Side") { - BookSideBySideComparison() -} - #Preview("TiledView (UICollectionView)") { BookTiledView() } - -#Preview("MessageList (LazyVStack)") { - BookMessageList() -} - -#Preview("Comparison") { - BookListComparison() -} diff --git a/Sources/MessagingUI/MessageList.swift b/Sources/MessagingUI/MessageList.swift deleted file mode 100644 index d12ae48..0000000 --- a/Sources/MessagingUI/MessageList.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// MessageList.swift -// swiftui-messaging-ui -// -// Created by Hiroshi Kimura on 2025/10/23. -// - -import SwiftUI - -/// Change type for MessageList to track prepend/append operations. -public enum ListDataSourceChangeType: Equatable, Sendable { - case setItems - case prepend - case append - case update - case remove -} - -/// # 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 -/// @State private var dataSource = ListDataSource(items: messages) -/// -/// MessageList(dataSource: dataSource) { message in -/// Text(message.text) -/// .padding(12) -/// .background(Color.blue.opacity(0.1)) -/// .cornerRadius(8) -/// } -/// ``` -public struct MessageList: View { - - private let dataSource: ListDataSource - private let content: (Message) -> Content - private let autoScrollToBottom: Binding? - - /// Creates a message list using a ListDataSource for change tracking. - /// - /// This initializer automatically detects prepend/append operations from the - /// data source's change history, enabling proper scroll position preservation. - /// - /// - Parameters: - /// - dataSource: A ListDataSource that tracks changes for efficient updates. - /// - autoScrollToBottom: Optional binding that controls automatic scrolling to bottom when new messages are added. - /// - content: A view builder that creates the view for each message. - public init( - dataSource: ListDataSource, - autoScrollToBottom: Binding? = nil, - @ViewBuilder content: @escaping (Message) -> Content - ) { - self.dataSource = dataSource - self.content = content - self.autoScrollToBottom = autoScrollToBottom - } - - public var body: some View { - _MessageListContent( - dataSource: dataSource, - autoScrollToBottom: autoScrollToBottom, - content: content - ) - } -} - -// MARK: - Internal Content View with State - -private struct _MessageListContent: View { - - let dataSource: ListDataSource - let autoScrollToBottom: Binding? - let content: (Message) -> Content - - /// Tracks which changes have been applied (cursor into pendingChanges) - @State private var appliedCursor: Int = 0 - /// Tracks the last DataSource ID to detect replacement - @State private var lastDataSourceID: UUID? - /// Anchor message ID to preserve scroll position during prepend - @State private var anchorMessageID: Message.ID? - /// Flag to indicate we're in a prepend operation - @State private var isPrepending: Bool = false - - var body: some View { - ScrollViewReader { proxy in - scrollContent - .defaultScrollAnchor(.bottom) - .modifier(ContentSizeChangeModifier( - isPrepending: $isPrepending, - anchorMessageID: $anchorMessageID, - autoScrollToBottom: autoScrollToBottom, - lastItemID: dataSource.items.last?.id, - proxy: proxy - )) - .onChange(of: dataSource.id) { _, newID in - // DataSource was replaced, reset cursor - lastDataSourceID = newID - appliedCursor = dataSource.pendingChanges.count - isPrepending = false - anchorMessageID = nil - } - .onChange(of: dataSource.changeCounter) { _, _ in - handleDataSourceChange() - } - .onAppear { - // Initialize tracking state - lastDataSourceID = dataSource.id - appliedCursor = dataSource.pendingChanges.count - } - } - } - - private var scrollContent: some View { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(dataSource.items) { message in - content(message) - .id(message.id) - } - } - } - } - - private func handleDataSourceChange() { - // Check if any pending change is prepend - let hasPrepend = dataSource.pendingChanges[appliedCursor...].contains { - if case .prepend = $0 { return true } - return false - } - - if hasPrepend { - // Remember the first item to anchor to after prepend - // After prepend, this item will no longer be at index 0 - if let firstVisibleID = dataSource.items.first?.id { - anchorMessageID = firstVisibleID - } - isPrepending = true - } else { - isPrepending = false - anchorMessageID = nil - } - - // Update cursor - appliedCursor = dataSource.pendingChanges.count - } -} - -// MARK: - Content Size Change Modifier - -private struct ContentSizeChangeModifier: ViewModifier { - @Binding var isPrepending: Bool - @Binding var anchorMessageID: ID? - let autoScrollToBottom: Binding? - let lastItemID: ID? - let proxy: ScrollViewProxy - - func body(content: Content) -> some View { - if #available(iOS 18.0, *) { - content - .onScrollGeometryChange(for: CGFloat.self) { geometry in - geometry.contentSize.height - } action: { oldHeight, newHeight in - handleContentSizeChange(oldHeight: oldHeight, newHeight: newHeight) - } - } else { - // iOS 17: No onScrollGeometryChange, use onChange of data as fallback - content - .onChange(of: anchorMessageID) { _, newValue in - // When anchorMessageID is set and then layout happens, - // scroll to anchor on next run loop - if isPrepending, let anchorID = newValue { - DispatchQueue.main.async { - proxy.scrollTo(anchorID, anchor: .top) - isPrepending = false - anchorMessageID = nil - } - } - } - .onChange(of: lastItemID) { _, newValue in - // Auto-scroll to bottom on append - if let autoScrollToBottom, - autoScrollToBottom.wrappedValue, - !isPrepending, - let lastID = newValue { - withAnimation(.easeOut(duration: 0.3)) { - proxy.scrollTo(lastID, anchor: .bottom) - } - } - } - } - } - - private func handleContentSizeChange(oldHeight: CGFloat, newHeight: CGFloat) { - let heightDiff = newHeight - oldHeight - guard heightDiff > 0 else { return } - - if isPrepending, let anchorID = anchorMessageID { - // Prepend: Scroll to anchor message to preserve position - proxy.scrollTo(anchorID, anchor: .top) - isPrepending = false - anchorMessageID = nil - } else if let autoScrollToBottom, - autoScrollToBottom.wrappedValue, - !isPrepending { - // Append: Auto-scroll to bottom - if let lastID = lastItemID { - withAnimation(.easeOut(duration: 0.3)) { - proxy.scrollTo(lastID, anchor: .bottom) - } - } - } - } -} From f3d97c3929365127f89ac80f89b05117721d0ced Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 03:34:31 +0900 Subject: [PATCH 20/27] Update --- CLAUDE.md | 1 + .../ApplyDiffDemo.swift | 3 + Dev/MessagingUIDevelopment/Cell.swift | 13 +++-- Dev/MessagingUIDevelopment/ContentView.swift | 52 ++++++++++++----- .../SwiftDataMemoDemo.swift | 2 + .../TiledViewDemo.swift | 56 ++++++++++++++++--- .../Tiled/TiledScrollPosition.swift | 23 ++++++++ Sources/MessagingUI/Tiled/TiledView.swift | 37 +++++++++++- 8 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Sources/MessagingUI/Tiled/TiledScrollPosition.swift diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6500ca5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- Always build for iOS \ No newline at end of file diff --git a/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift index ecd504f..aefb207 100644 --- a/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift +++ b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift @@ -28,6 +28,8 @@ struct BookApplyDiffDemo: View { /// Previous change counter to detect new changes @State private var previousChangeCounter = 0 + @State private var scrollPosition = TiledScrollPosition() + var body: some View { VStack(spacing: 0) { // Control Panel @@ -181,6 +183,7 @@ struct BookApplyDiffDemo: View { // List View TiledView( dataSource: dataSource, + scrollPosition: $scrollPosition, cellBuilder: { message in ChatBubbleView(message: message) } diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index 57398b3..61fa135 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -41,6 +41,12 @@ struct ChatBubbleView: View { var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { + Button("Expand") { + withAnimation(.smooth) { + isLocalExpanded.toggle() + } + } + .font(.caption2) HStack { Text("ID: \(message.id)") .font(.caption) @@ -96,12 +102,7 @@ struct ChatBubbleView: View { Spacer(minLength: 44) } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.smooth) { - isLocalExpanded.toggle() - } - } + .contentShape(Rectangle()) .padding(.horizontal, 16) .padding(.vertical, 8) .background(Color.init(white: 0.1, opacity: 0.5)) diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 7838dcd..1376810 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -6,17 +6,23 @@ // import SwiftUI +import MessagingUI + +enum DemoDestination: Hashable { + case tiledView + case applyDiffDemo + case swiftDataMemo +} struct ContentView: View { + + @Namespace private var namespace + var body: some View { NavigationStack { List { Section("Demos") { - NavigationLink { - BookTiledView() - .navigationTitle("TiledView") - .navigationBarTitleDisplayMode(.inline) - } label: { + NavigationLink(value: DemoDestination.tiledView) { Label { VStack(alignment: .leading) { Text("TiledView") @@ -29,11 +35,7 @@ struct ContentView: View { } } - NavigationLink { - BookApplyDiffDemo() - .navigationTitle("applyDiff Demo") - .navigationBarTitleDisplayMode(.inline) - } label: { + NavigationLink(value: DemoDestination.applyDiffDemo) { Label { VStack(alignment: .leading) { Text("applyDiff Demo") @@ -48,10 +50,7 @@ struct ContentView: View { } Section("SwiftData Integration") { - NavigationLink { - SwiftDataMemoDemo() - .navigationBarTitleDisplayMode(.inline) - } label: { + NavigationLink(value: DemoDestination.swiftDataMemo) { Label { VStack(alignment: .leading) { Text("Memo Stream") @@ -66,6 +65,31 @@ struct ContentView: View { } } .navigationTitle("MessagingUI") + .navigationDestination(for: DemoDestination.self) { destination in + switch destination { + case .tiledView: + BookTiledView(namespace: namespace) + .navigationTitle("TiledView") + .navigationBarTitleDisplayMode(.inline) + case .applyDiffDemo: + BookApplyDiffDemo() + .navigationTitle("applyDiff Demo") + .navigationBarTitleDisplayMode(.inline) + case .swiftDataMemo: + SwiftDataMemoDemo() + .navigationBarTitleDisplayMode(.inline) + } + } + .navigationDestination(for: ChatMessage.self) { message in + if #available(iOS 18.0, *) { + Text("Detail View for Message ID: \(message.id)") + .navigationTransition( + .zoom(sourceID: message.id, in: namespace) + ) + } else { + Text("Detail View for Message ID: \(message.id)") + } + } } } } diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift index dc89681..d3418f7 100644 --- a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -174,6 +174,7 @@ struct SwiftDataMemoDemo: View { @Environment(\.modelContext) private var modelContext @State private var store: MemoStore? @State private var inputText = "" + @State private var scrollPosition = TiledScrollPosition() var body: some View { VStack(spacing: 0) { @@ -222,6 +223,7 @@ struct SwiftDataMemoDemo: View { if let store { TiledView( dataSource: store.dataSource, + scrollPosition: $scrollPosition, onPrepend: { store.loadMore() }, diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 164a20d..8607119 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -118,8 +118,12 @@ struct BookTiledView: View { @State private var dataSource: ListDataSource @State private var nextPrependId = -1 @State private var nextAppendId = 0 + @State private var scrollPosition = TiledScrollPosition() - init() { + let namespace: Namespace.ID + + init(namespace: Namespace.ID) { + self.namespace = namespace _dataSource = State(initialValue: ListDataSource()) } @@ -133,16 +137,52 @@ struct BookTiledView: View { .padding() .background(Color(.systemBackground)) - TiledView( - dataSource: dataSource, - cellBuilder: { message in - ChatBubbleView(message: message) - } - ) + if #available(iOS 18.0, *) { + TiledView( + dataSource: dataSource, + scrollPosition: $scrollPosition, + cellBuilder: { message in + NavigationLink(value: message) { + ChatBubbleView(message: message) + .matchedTransitionSource( + id: message.id, + in: namespace + ) + } + } + ) + } else { + TiledView( + dataSource: dataSource, + scrollPosition: $scrollPosition, + cellBuilder: { message in + ChatBubbleView(message: message) + } + ) + } } } } #Preview("TiledView (UICollectionView)") { - BookTiledView() + struct PreviewWrapper: View { + @Namespace private var namespace + + var body: some View { + NavigationStack { + BookTiledView(namespace: namespace) + .navigationDestination(for: ChatMessage.self) { message in + if #available(iOS 18.0, *) { + Text("Detail View for Message ID: \(message.id)") + .navigationTransition( + .zoom(sourceID: message.id, in: namespace) + ) + } else { + Text("Detail View for Message ID: \(message.id)") + } + } + } + } + } + return PreviewWrapper() } diff --git a/Sources/MessagingUI/Tiled/TiledScrollPosition.swift b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift new file mode 100644 index 0000000..a7eb88c --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift @@ -0,0 +1,23 @@ + +public struct TiledScrollPosition: Equatable, Sendable { + + public enum Edge: Equatable, Sendable { + case top + case bottom + } + + /// Current scroll edge + var edge: Edge? + var animated: Bool = true + + /// Version for change tracking + private(set) var version: UInt = 0 + + public init() {} + + public mutating func scrollTo(edge: Edge, animated: Bool = true) { + self.edge = edge + self.animated = animated + self.version += 1 + } +} diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 9397531..ddf88df 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -74,6 +74,9 @@ public final class _TiledView: UIVie private let prependThreshold: CGFloat = 100 private var prependTask: Task? + /// Scroll position tracking + private var lastAppliedScrollVersion: UInt = 0 + public typealias DataSource = ListDataSource public let onPrepend: (@MainActor () async throws -> Void)? @@ -250,6 +253,32 @@ public final class _TiledView: UIVie isPrependTriggered = false } } + + // MARK: - Scroll Position + + func applyScrollPosition(_ position: TiledScrollPosition) { + guard position.version != lastAppliedScrollVersion else { return } + lastAppliedScrollVersion = position.version + + guard let edge = position.edge else { return } + + switch edge { + case .top: + guard items.count > 0 else { return } + collectionView.scrollToItem( + at: IndexPath(item: 0, section: 0), + at: .top, + animated: position.animated + ) + case .bottom: + guard items.count > 0 else { return } + collectionView.scrollToItem( + at: IndexPath(item: items.count - 1, section: 0), + at: .bottom, + animated: position.animated + ) + } + } } // MARK: - TiledView @@ -260,14 +289,17 @@ public struct TiledView: UIViewRepre let dataSource: ListDataSource let cellBuilder: (Item) -> Cell - let onPrepend: (() async throws -> Void)? + let onPrepend: (@MainActor () async throws -> Void)? + @Binding var scrollPosition: TiledScrollPosition public init( dataSource: ListDataSource, - onPrepend: (() async throws -> Void)? = nil, + scrollPosition: Binding, + onPrepend: (@MainActor () async throws -> Void)? = nil, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self.dataSource = dataSource + self._scrollPosition = scrollPosition self.onPrepend = onPrepend self.cellBuilder = cellBuilder } @@ -280,5 +312,6 @@ public struct TiledView: UIViewRepre public func updateUIView(_ uiView: _TiledView, context: Context) { uiView.applyDataSource(dataSource) + uiView.applyScrollPosition(scrollPosition) } } From 2a17d19b99378667cf5f5cc9e128726a9da0f016 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 03:53:21 +0900 Subject: [PATCH 21/27] Update --- .../SwiftDataMemoDemo.swift | 45 ++++++++--- .../TiledViewDemo.swift | 77 ++++++++++++++++--- .../Tiled/TiledCollectionViewLayout.swift | 32 +++++++- Sources/MessagingUI/Tiled/TiledView.swift | 6 +- 4 files changed, 135 insertions(+), 25 deletions(-) diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift index d3418f7..138a570 100644 --- a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -41,6 +41,7 @@ struct MemoItem: Identifiable, Equatable { struct MemoBubbleView: View { let item: MemoItem + var onDelete: (() -> Void)? private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -70,6 +71,15 @@ struct MemoBubbleView: View { } .padding(.horizontal, 16) .padding(.vertical, 4) + .contextMenu { + if let onDelete { + Button(role: .destructive) { + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + } + } } } @@ -82,7 +92,7 @@ final class MemoStore { private(set) var dataSource = ListDataSource() private(set) var hasMore = true - /// 現在ロード済みの件数(ページネーション用) + /// Current loaded item count for pagination private var loadedCount = 0 private let pageSize = 10 @@ -90,27 +100,27 @@ final class MemoStore { self.modelContext = modelContext } - /// 初期ロード: 最新10件を取得 + /// Initial load: fetch latest 10 items func loadInitial() { loadedCount = pageSize refreshFromDatabase() } - /// 過去のメモをロード: 取得件数を増やして再フェッチ + /// Load older memos: increase fetch count and re-fetch func loadMore() { guard hasMore else { return } loadedCount += pageSize refreshFromDatabase() } - /// SwiftDataから取得してapplyDiffで差分適用 + /// Fetch from SwiftData and apply diff private func refreshFromDatabase() { - // 全件数を取得してoffsetを計算 + // Get total count to calculate offset let totalCount = (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 let offset = max(0, totalCount - loadedCount) var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.createdAt, order: .forward)] // 古い→新しい順 + sortBy: [SortDescriptor(\.createdAt, order: .forward)] // oldest to newest ) descriptor.fetchOffset = offset descriptor.fetchLimit = loadedCount @@ -118,23 +128,34 @@ final class MemoStore { let memos = (try? modelContext.fetch(descriptor)) ?? [] let items = memos.map(MemoItem.init) - // applyDiffで自動的に差分を検出・適用 + // Automatically detect and apply diff dataSource.applyDiff(from: items) hasMore = offset > 0 } - /// 新規メモ追加後にリフレッシュ + /// Add new memo and refresh func addMemo(text: String) { let memo = Memo(text: text) modelContext.insert(memo) try? modelContext.save() - // 追加後は件数を1つ増やしてリフレッシュ + // Increment count and refresh after adding loadedCount += 1 refreshFromDatabase() } + /// Delete memo by ID and refresh + func deleteMemo(id: PersistentIdentifier) { + guard let memo = modelContext.model(for: id) as? Memo else { return } + modelContext.delete(memo) + try? modelContext.save() + + // Decrement count and refresh after deleting + loadedCount = max(0, loadedCount - 1) + refreshFromDatabase() + } + private static let sampleTexts = [ "Hello!", "How are you today?", @@ -161,7 +182,7 @@ final class MemoStore { } try? modelContext.save() - // 追加した分だけ件数を増やしてリフレッシュ + // Increment count by added amount and refresh loadedCount += count refreshFromDatabase() } @@ -228,7 +249,9 @@ struct SwiftDataMemoDemo: View { store.loadMore() }, cellBuilder: { item in - MemoBubbleView(item: item) + MemoBubbleView(item: item) { + store.deleteMemo(id: item.id) + } } ) } else { diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 8607119..0269e28 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -119,6 +119,7 @@ struct BookTiledView: View { @State private var nextPrependId = -1 @State private var nextAppendId = 0 @State private var scrollPosition = TiledScrollPosition() + @State private var showingActionSheet = false let namespace: Namespace.ID @@ -128,15 +129,7 @@ struct BookTiledView: View { } var body: some View { - VStack(spacing: 0) { - ListDemoControlPanel( - dataSource: $dataSource, - nextPrependId: $nextPrependId, - nextAppendId: $nextAppendId - ) - .padding() - .background(Color(.systemBackground)) - + Group { if #available(iOS 18.0, *) { TiledView( dataSource: dataSource, @@ -161,6 +154,72 @@ struct BookTiledView: View { ) } } + .safeAreaInset(edge: .bottom) { + HStack { + Text("\(dataSource.items.count) items") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("v\(dataSource.changeCounter)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Menu { + Button { + let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) + dataSource.prepend(messages) + nextPrependId -= 5 + } label: { + Label("Prepend 5", systemImage: "arrow.up.doc") + } + + Button { + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + dataSource.append(messages) + nextAppendId += 5 + } label: { + Label("Append 5", systemImage: "arrow.down.doc") + } + + Divider() + + Button { + if var item = dataSource.items.first(where: { $0.id == 5 }) { + item.isExpanded.toggle() + item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" + dataSource.update([item]) + } + } label: { + Label("Update ID:5", systemImage: "pencil") + } + + Button(role: .destructive) { + dataSource.remove(id: 10) + } label: { + Label("Remove ID:10", systemImage: "trash") + } + + Divider() + + Button { + nextPrependId = -1 + nextAppendId = 5 + let newItems = generateSampleMessages(count: 5, startId: 0) + dataSource.setItems(newItems) + } label: { + Label("Reset (5 items)", systemImage: "arrow.counterclockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } } } diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 5c6b3d9..d091559 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -18,8 +18,8 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { /// When enabled, attributes are reused instead of being recreated on each prepare() call. public var usesAttributesCache: Bool = false - /// サイズを問い合わせるclosure。indexとwidthを渡し、サイズを返す。 - /// nilを返した場合はestimatedHeightを使用。 + /// Closure to query item size. Receives index and width, returns size. + /// If nil is returned, estimatedHeight will be used. public var itemSizeProvider: ((_ index: Int, _ width: CGFloat) -> CGSize?)? // MARK: - Private Properties @@ -47,7 +47,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { public override func prepare() { guard let collectionView else { return } - // contentInsetを自動更新 + // Automatically update contentInset collectionView.contentInset = calculateContentInset() let boundsSize = collectionView.bounds.size @@ -168,7 +168,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { public func prependItems(count: Int) { let width = collectionView?.bounds.width ?? 0 - // prependは逆順で処理(index 0から順に挿入するため) + // Process in reverse order for prepend (to insert from index 0 sequentially) for i in (0..) + + for index in sortedIndices { + guard index >= 0, index < itemYPositions.count else { continue } + + let removedHeight = itemHeights[index] + + // Remove the item + itemYPositions.remove(at: index) + itemHeights.remove(at: index) + + // Shift all items after the removal point + for i in index..: UIVie case .remove(let ids): let idsSet = Set(ids) + // Find indices before removing items + let indicesToRemove = items.enumerated() + .filter { idsSet.contains($0.element.id) } + .map { $0.offset } items.removeAll { idsSet.contains($0.id) } - // TODO: Update layout to handle removal + tiledLayout.removeItems(at: indicesToRemove) collectionView.reloadData() } } From 6f8892d0b3afc4fd0dfd9a46741137f32a04941f Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 04:02:20 +0900 Subject: [PATCH 22/27] Cleanup --- .../Tiled/TiledCollectionViewLayout.swift | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index d091559..9fe4e01 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -22,7 +22,13 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { /// If nil is returned, estimatedHeight will be used. public var itemSizeProvider: ((_ index: Int, _ width: CGFloat) -> CGSize?)? - // MARK: - Private Properties + // MARK: - Constants + + private let virtualContentHeight: CGFloat = 100_000_000 + private let anchorY: CGFloat = 50_000_000 + private let estimatedHeight: CGFloat = 100 + + // MARK: - Private State private var attributesCache: [IndexPath: UICollectionViewLayoutAttributes] = [:] private var itemYPositions: Deque = [] @@ -30,8 +36,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { private var lastPreparedBoundsSize: CGSize = .zero private var needsFullAttributesRebuild: Bool = true - private let virtualContentHeight: CGFloat = 100_000_000 - private let anchorY: CGFloat = 50_000_000 + // MARK: - UICollectionViewLayout Overrides public override var collectionViewContentSize: CGSize { CGSize( @@ -66,12 +71,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { let indexPath = IndexPath(item: index, section: 0) let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) - attributes.frame = CGRect( - x: 0, - y: itemYPositions[index], - width: boundsSize.width, - height: itemHeights[index] - ) + attributes.frame = makeFrame(at: index, boundsWidth: boundsSize.width) attributesCache[indexPath] = attributes } @@ -83,21 +83,11 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { let indexPath = IndexPath(item: index, section: 0) if let attributes = attributesCache[indexPath] { - attributes.frame = CGRect( - x: 0, - y: itemYPositions[index], - width: boundsSize.width, - height: itemHeights[index] - ) + attributes.frame = makeFrame(at: index, boundsWidth: boundsSize.width) } else { // New item added, create attributes let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) - attributes.frame = CGRect( - x: 0, - y: itemYPositions[index], - width: boundsSize.width, - height: itemHeights[index] - ) + attributes.frame = makeFrame(at: index, boundsWidth: boundsSize.width) attributesCache[indexPath] = attributes } } @@ -145,7 +135,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { return context } - private let estimatedHeight: CGFloat = 100 + // MARK: - Public Item Management API public func appendItems(count: Int, startingIndex: Int) { let width = collectionView?.bounds.width ?? 0 @@ -210,27 +200,6 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { needsFullAttributesRebuild = true } - private func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { - guard let firstY = itemYPositions.first, - let lastY = itemYPositions.last, - let lastHeight = itemHeights.last else { return nil } - return (firstY, lastY + lastHeight) - } - - private func calculateContentInset() -> UIEdgeInsets { - guard let bounds = contentBounds() else { return .zero } - - let topInset = bounds.top - let bottomInset = virtualContentHeight - bounds.bottom - - return UIEdgeInsets( - top: -topInset, - left: 0, - bottom: -bottomInset, - right: 0 - ) - } - public func removeItems(at indices: [Int]) { guard !indices.isEmpty else { return } @@ -262,7 +231,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { needsFullAttributesRebuild = true } - public func updateItemHeight(at index: Int, newHeight: CGFloat) { + private func updateItemHeight(at index: Int, newHeight: CGFloat) { guard index >= 0, index < itemHeights.count else { return } let oldHeight = itemHeights[index] @@ -275,4 +244,36 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { itemYPositions[i] += heightDiff } } + + // MARK: - Private Helpers + + private func makeFrame(at index: Int, boundsWidth: CGFloat) -> CGRect { + CGRect( + x: 0, + y: itemYPositions[index], + width: boundsWidth, + height: itemHeights[index] + ) + } + + private func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { + guard let firstY = itemYPositions.first, + let lastY = itemYPositions.last, + let lastHeight = itemHeights.last else { return nil } + return (firstY, lastY + lastHeight) + } + + private func calculateContentInset() -> UIEdgeInsets { + guard let bounds = contentBounds() else { return .zero } + + let topInset = bounds.top + let bottomInset = virtualContentHeight - bounds.bottom + + return UIEdgeInsets( + top: -topInset, + left: 0, + bottom: -bottomInset, + right: 0 + ) + } } From 0d48c7ab988af0f5ffd22b7df9c5ad18b8816ed0 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 18:00:29 +0900 Subject: [PATCH 23/27] WIP --- .claude/settings.local.json | 3 +- .../TiledViewDemo.swift | 185 ++++++++++++------ Sources/MessagingUI/Tiled/TiledView.swift | 55 +++++- 3 files changed, 182 insertions(+), 61 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 130ac47..26b8756 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "mcp__XcodeBuildMCP__discover_projs", "mcp__sosumi__searchAppleDocumentation", - "WebFetch(domain:medium.com)" + "WebFetch(domain:medium.com)", + "Bash(tee:*)" ] } } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 0269e28..a68f0f1 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -119,7 +119,9 @@ struct BookTiledView: View { @State private var nextPrependId = -1 @State private var nextAppendId = 0 @State private var scrollPosition = TiledScrollPosition() - @State private var showingActionSheet = false + + // TiledView options + @State private var cachesCellState = false let namespace: Namespace.ID @@ -134,13 +136,10 @@ struct BookTiledView: View { TiledView( dataSource: dataSource, scrollPosition: $scrollPosition, + cachesCellState: cachesCellState, cellBuilder: { message in NavigationLink(value: message) { - ChatBubbleView(message: message) - .matchedTransitionSource( - id: message.id, - in: namespace - ) + StatefulChatBubbleView(message: message, namespace: namespace) } } ) @@ -148,81 +147,153 @@ struct BookTiledView: View { TiledView( dataSource: dataSource, scrollPosition: $scrollPosition, + cachesCellState: cachesCellState, cellBuilder: { message in - ChatBubbleView(message: message) + StatefulChatBubbleView(message: message, namespace: nil) } ) } } .safeAreaInset(edge: .bottom) { - HStack { - Text("\(dataSource.items.count) items") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text("v\(dataSource.changeCounter)") - .font(.caption2) - .foregroundStyle(.tertiary) + VStack(spacing: 0) { + Divider() + HStack { + Text("\(dataSource.items.count) items") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + HStack(spacing: 12) { + Toggle("Cache State", isOn: $cachesCellState) + .font(.caption) + .toggleStyle(.switch) + .controlSize(.mini) + Text("v\(dataSource.changeCounter)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) } - .padding(.horizontal) - .padding(.vertical, 8) .background(.bar) } .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Menu { - Button { - let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) - dataSource.prepend(messages) - nextPrependId -= 5 - } label: { - Label("Prepend 5", systemImage: "arrow.up.doc") - } + ToolbarItemGroup(placement: .bottomBar) { + // Prepend + Button { + let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) + dataSource.prepend(messages) + nextPrependId -= 5 + } label: { + Image(systemName: "arrow.up.doc") + } - Button { - let messages = generateSampleMessages(count: 5, startId: nextAppendId) - dataSource.append(messages) - nextAppendId += 5 - } label: { - Label("Append 5", systemImage: "arrow.down.doc") - } + // Append + Button { + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + dataSource.append(messages) + nextAppendId += 5 + } label: { + Image(systemName: "arrow.down.doc") + } - Divider() + // Insert at middle + Button { + let middleIndex = dataSource.items.count / 2 + let message = ChatMessage(id: nextAppendId, text: "Inserted at \(middleIndex)") + dataSource.insert([message], at: middleIndex) + nextAppendId += 1 + } label: { + Image(systemName: "arrow.right.doc.on.clipboard") + } - Button { - if var item = dataSource.items.first(where: { $0.id == 5 }) { - item.isExpanded.toggle() - item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" - dataSource.update([item]) - } - } label: { - Label("Update ID:5", systemImage: "pencil") - } + Spacer() - Button(role: .destructive) { - dataSource.remove(id: 10) - } label: { - Label("Remove ID:10", systemImage: "trash") + // Update + Button { + if var item = dataSource.items.first(where: { $0.id == 5 }) { + item.isExpanded.toggle() + item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" + dataSource.update([item]) } + } label: { + Image(systemName: "pencil") + } - Divider() + // Remove + Button { + dataSource.remove(id: 10) + } label: { + Image(systemName: "trash") + } - Button { - nextPrependId = -1 - nextAppendId = 5 - let newItems = generateSampleMessages(count: 5, startId: 0) - dataSource.setItems(newItems) - } label: { - Label("Reset (5 items)", systemImage: "arrow.counterclockwise") - } + Spacer() + + // Scroll to Top + Button { + scrollPosition.scrollTo(edge: .top) + } label: { + Image(systemName: "arrow.up.to.line") + } + + // Scroll to Bottom + Button { + scrollPosition.scrollTo(edge: .bottom) } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "arrow.down.to.line") + } + + Spacer() + + // Reset + Button { + nextPrependId = -1 + nextAppendId = 5 + let newItems = generateSampleMessages(count: 5, startId: 0) + dataSource.setItems(newItems) + } label: { + Image(systemName: "arrow.counterclockwise") } } } } } +// MARK: - StatefulChatBubbleView + +/// A chat bubble view with internal @State to demonstrate state persistence. +/// When cachesCellState is enabled, the tap count persists across cell reuse. +struct StatefulChatBubbleView: View { + + let message: ChatMessage + let namespace: Namespace.ID? + + @State private var tapCount = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + chatBubbleContent + Text("Taps: \(tapCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + tapCount += 1 + } + } + + @ViewBuilder + private var chatBubbleContent: some View { + if #available(iOS 18.0, *), let namespace { + ChatBubbleView(message: message) + .matchedTransitionSource(id: message.id, in: namespace) + } else { + ChatBubbleView(message: message) + } + } +} + #Preview("TiledView (UICollectionView)") { struct PreviewWrapper: View { @Namespace private var namespace diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 7b5eab9..e67ed76 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -23,7 +23,8 @@ public final class TiledViewCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() - contentConfiguration = nil + // Note: contentConfiguration is not cleared here to support State caching. + // When cachesCellState is enabled, the configuration is managed by _TiledView. } public override func preferredLayoutAttributesFitting( @@ -77,15 +78,27 @@ public final class _TiledView: UIVie /// Scroll position tracking private var lastAppliedScrollVersion: UInt = 0 + /// Configuration cache for State persistence + /// When cachesCellState is enabled, UIHostingConfiguration is cached per Item.ID + /// to preserve SwiftUI @State across cell reuse. + private var configurationCache: [Item.ID: UIContentConfiguration] = [:] + + /// Whether to cache UIHostingConfiguration to preserve SwiftUI @State. + /// When enabled, each Item gets a persistent configuration, maintaining State. + /// When disabled (default), configurations are recreated on each cell reuse. + public let cachesCellState: Bool + public typealias DataSource = ListDataSource public let onPrepend: (@MainActor () async throws -> Void)? public init( cellBuilder: @escaping (Item) -> Cell, + cachesCellState: Bool = false, onPrepend: (@MainActor () async throws -> Void)? = nil ) { self.cellBuilder = cellBuilder + self.cachesCellState = cachesCellState self.onPrepend = onPrepend super.init(frame: .zero) setupCollectionView() @@ -170,6 +183,9 @@ public final class _TiledView: UIVie private func applyChange(_ change: ListDataSource.Change, from dataSource: ListDataSource) { switch change { case .setItems: + if cachesCellState { + configurationCache.removeAll() + } tiledLayout.clear() items = dataSource.items tiledLayout.appendItems(count: items.count, startingIndex: 0) @@ -213,6 +229,14 @@ public final class _TiledView: UIVie .map { $0.offset } items.removeAll { idsSet.contains($0.id) } tiledLayout.removeItems(at: indicesToRemove) + + // Clean up configuration cache for removed items + if cachesCellState { + for id in ids { + configurationCache.removeValue(forKey: id) + } + } + collectionView.reloadData() } } @@ -230,7 +254,25 @@ public final class _TiledView: UIVie public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TiledViewCell.reuseIdentifier, for: indexPath) as! TiledViewCell let item = items[indexPath.item] - cell.configure(with: cellBuilder(item)) + + if cachesCellState { + // Cache mode: preserve SwiftUI @State by reusing configurations + if let cachedConfig = configurationCache[item.id] { + cell.contentConfiguration = cachedConfig + } else { + let config = UIHostingConfiguration { + cellBuilder(item) + } + .margins(.all, 0) + + configurationCache[item.id] = config + cell.contentConfiguration = config + } + } else { + // Non-cache mode: traditional behavior + cell.configure(with: cellBuilder(item)) + } + return cell } @@ -296,20 +338,27 @@ public struct TiledView: UIViewRepre let onPrepend: (@MainActor () async throws -> Void)? @Binding var scrollPosition: TiledScrollPosition + /// Whether to cache UIHostingConfiguration to preserve SwiftUI @State. + /// When enabled, each Item gets a persistent configuration, maintaining State across cell reuse. + /// When disabled (default), configurations are recreated on each cell reuse for better memory efficiency. + let cachesCellState: Bool + public init( dataSource: ListDataSource, scrollPosition: Binding, + cachesCellState: Bool = false, onPrepend: (@MainActor () async throws -> Void)? = nil, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self.dataSource = dataSource self._scrollPosition = scrollPosition + self.cachesCellState = cachesCellState self.onPrepend = onPrepend self.cellBuilder = cellBuilder } public func makeUIView(context: Context) -> _TiledView { - let view = _TiledView(cellBuilder: cellBuilder, onPrepend: onPrepend) + let view = _TiledView(cellBuilder: cellBuilder, cachesCellState: cachesCellState, onPrepend: onPrepend) view.applyDataSource(dataSource) return view } From 0d9aa73124cd3f5ed8c66f087875e7aa5de8a7f9 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 18:26:42 +0900 Subject: [PATCH 24/27] Update --- Dev/MessagingUIDevelopment/Cell.swift | 77 ++++++--------- Dev/MessagingUIDevelopment/ContentView.swift | 13 +-- .../TiledViewDemo.swift | 96 +++---------------- Sources/MessagingUI/Tiled/TiledView.swift | 63 ++---------- 4 files changed, 51 insertions(+), 198 deletions(-) diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index 61fa135..fb95edd 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -32,66 +32,49 @@ func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { // MARK: - Chat Bubble View +/// Cell with @State to demonstrate state persistence. +/// When cachesCellState is enabled, counter and isExpanded persist across cell reuse. struct ChatBubbleView: View { let message: ChatMessage - @State private var isLocalExpanded: Bool = true + @State private var counter = 0 + @State private var isExpanded = false var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { - Button("Expand") { - withAnimation(.smooth) { - isLocalExpanded.toggle() - } - } - .font(.caption2) HStack { Text("ID: \(message.id)") .font(.caption) .foregroundStyle(.secondary) - Spacer() - - Image(systemName: isLocalExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) + Button { + counter += 1 + } label: { + Text("\(counter)") + .font(.caption) + .monospacedDigit() + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(counter > 0 ? Color.blue : Color(.systemGray5)) + ) + .foregroundStyle(counter > 0 ? .white : .secondary) + } + .buttonStyle(.plain) } Text(message.text) .font(.system(size: 16)) .fixedSize(horizontal: false, vertical: true) - if message.isExpanded { - Text("(DataSource expanded)") - .font(.system(size: 14)) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - - - if isLocalExpanded { - VStack(alignment: .leading, spacing: 10) { - Text("Local expanded content") - .font(.system(size: 14)) - .foregroundStyle(.blue) - .fixedSize(horizontal: false, vertical: true) - - Text("This is additional content that appears when you tap the cell. It demonstrates that local @State changes can also affect cell height.") - .font(.system(size: 12)) - .fixedSize(horizontal: false, vertical: true) - - HStack { - ForEach(0..<3) { i in - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 30, height: 30) - .overlay(Text("\(i + 1)").font(.caption2)) - } - } - } - .padding(.top, 8) + if isExpanded { + Text("Expanded (local @State)") + .font(.caption) + .foregroundStyle(.blue) + .padding(.top, 4) } } .padding(12) @@ -102,14 +85,14 @@ struct ChatBubbleView: View { Spacer(minLength: 44) } - .contentShape(Rectangle()) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.init(white: 0.1, opacity: 0.5)) - .contextMenu { - Button("Hello") { + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.snappy) { + isExpanded.toggle() } } + .padding(.horizontal, 16) + .padding(.vertical, 4) } } diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 1376810..50c65c0 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -16,8 +16,6 @@ enum DemoDestination: Hashable { struct ContentView: View { - @Namespace private var namespace - var body: some View { NavigationStack { List { @@ -68,7 +66,7 @@ struct ContentView: View { .navigationDestination(for: DemoDestination.self) { destination in switch destination { case .tiledView: - BookTiledView(namespace: namespace) + BookTiledView() .navigationTitle("TiledView") .navigationBarTitleDisplayMode(.inline) case .applyDiffDemo: @@ -81,14 +79,7 @@ struct ContentView: View { } } .navigationDestination(for: ChatMessage.self) { message in - if #available(iOS 18.0, *) { - Text("Detail View for Message ID: \(message.id)") - .navigationTransition( - .zoom(sourceID: message.id, in: namespace) - ) - } else { - Text("Detail View for Message ID: \(message.id)") - } + Text("Detail View for Message ID: \(message.id)") } } } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index a68f0f1..63bd691 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -115,7 +115,7 @@ struct ListDemoControlPanel: View { struct BookTiledView: View { - @State private var dataSource: ListDataSource + @State private var dataSource = ListDataSource() @State private var nextPrependId = -1 @State private var nextAppendId = 0 @State private var scrollPosition = TiledScrollPosition() @@ -123,37 +123,17 @@ struct BookTiledView: View { // TiledView options @State private var cachesCellState = false - let namespace: Namespace.ID - - init(namespace: Namespace.ID) { - self.namespace = namespace - _dataSource = State(initialValue: ListDataSource()) - } - var body: some View { - Group { - if #available(iOS 18.0, *) { - TiledView( - dataSource: dataSource, - scrollPosition: $scrollPosition, - cachesCellState: cachesCellState, - cellBuilder: { message in - NavigationLink(value: message) { - StatefulChatBubbleView(message: message, namespace: namespace) - } - } - ) - } else { - TiledView( - dataSource: dataSource, - scrollPosition: $scrollPosition, - cachesCellState: cachesCellState, - cellBuilder: { message in - StatefulChatBubbleView(message: message, namespace: nil) - } - ) + TiledView( + dataSource: dataSource, + scrollPosition: $scrollPosition, + cachesCellState: cachesCellState, + cellBuilder: { message in + NavigationLink(value: message) { + ChatBubbleView(message: message) + } } - } + ) .safeAreaInset(edge: .bottom) { VStack(spacing: 0) { Divider() @@ -259,60 +239,8 @@ struct BookTiledView: View { } } -// MARK: - StatefulChatBubbleView - -/// A chat bubble view with internal @State to demonstrate state persistence. -/// When cachesCellState is enabled, the tap count persists across cell reuse. -struct StatefulChatBubbleView: View { - - let message: ChatMessage - let namespace: Namespace.ID? - - @State private var tapCount = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - chatBubbleContent - Text("Taps: \(tapCount)") - .font(.caption2) - .foregroundStyle(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - tapCount += 1 - } - } - - @ViewBuilder - private var chatBubbleContent: some View { - if #available(iOS 18.0, *), let namespace { - ChatBubbleView(message: message) - .matchedTransitionSource(id: message.id, in: namespace) - } else { - ChatBubbleView(message: message) - } - } -} - #Preview("TiledView (UICollectionView)") { - struct PreviewWrapper: View { - @Namespace private var namespace - - var body: some View { - NavigationStack { - BookTiledView(namespace: namespace) - .navigationDestination(for: ChatMessage.self) { message in - if #available(iOS 18.0, *) { - Text("Detail View for Message ID: \(message.id)") - .navigationTransition( - .zoom(sourceID: message.id, in: namespace) - ) - } else { - Text("Detail View for Message ID: \(message.id)") - } - } - } - } + NavigationStack { + BookTiledView() } - return PreviewWrapper() } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index e67ed76..f4b81ab 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -23,8 +23,7 @@ public final class TiledViewCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() - // Note: contentConfiguration is not cleared here to support State caching. - // When cachesCellState is enabled, the configuration is managed by _TiledView. + contentConfiguration = nil } public override func preferredLayoutAttributesFitting( @@ -57,14 +56,14 @@ public final class TiledViewCell: UICollectionViewCell { public final class _TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { - private var collectionView: UICollectionView! - private var tiledLayout: TiledCollectionViewLayout! + private unowned var collectionView: UICollectionView! + private unowned var tiledLayout: TiledCollectionViewLayout! private var items: [Item] = [] private let cellBuilder: (Item) -> Cell - /// サイズ計測用のCell(再利用) - private lazy var sizingCell = TiledViewCell() + /// prototype cell for size measurement + private let sizingCell = TiledViewCell() /// DataSource tracking private var lastDataSourceID: UUID? @@ -78,27 +77,15 @@ public final class _TiledView: UIVie /// Scroll position tracking private var lastAppliedScrollVersion: UInt = 0 - /// Configuration cache for State persistence - /// When cachesCellState is enabled, UIHostingConfiguration is cached per Item.ID - /// to preserve SwiftUI @State across cell reuse. - private var configurationCache: [Item.ID: UIContentConfiguration] = [:] - - /// Whether to cache UIHostingConfiguration to preserve SwiftUI @State. - /// When enabled, each Item gets a persistent configuration, maintaining State. - /// When disabled (default), configurations are recreated on each cell reuse. - public let cachesCellState: Bool - public typealias DataSource = ListDataSource public let onPrepend: (@MainActor () async throws -> Void)? public init( cellBuilder: @escaping (Item) -> Cell, - cachesCellState: Bool = false, onPrepend: (@MainActor () async throws -> Void)? = nil ) { self.cellBuilder = cellBuilder - self.cachesCellState = cachesCellState self.onPrepend = onPrepend super.init(frame: .zero) setupCollectionView() @@ -183,9 +170,6 @@ public final class _TiledView: UIVie private func applyChange(_ change: ListDataSource.Change, from dataSource: ListDataSource) { switch change { case .setItems: - if cachesCellState { - configurationCache.removeAll() - } tiledLayout.clear() items = dataSource.items tiledLayout.appendItems(count: items.count, startingIndex: 0) @@ -229,14 +213,6 @@ public final class _TiledView: UIVie .map { $0.offset } items.removeAll { idsSet.contains($0.id) } tiledLayout.removeItems(at: indicesToRemove) - - // Clean up configuration cache for removed items - if cachesCellState { - for id in ids { - configurationCache.removeValue(forKey: id) - } - } - collectionView.reloadData() } } @@ -254,25 +230,7 @@ public final class _TiledView: UIVie public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TiledViewCell.reuseIdentifier, for: indexPath) as! TiledViewCell let item = items[indexPath.item] - - if cachesCellState { - // Cache mode: preserve SwiftUI @State by reusing configurations - if let cachedConfig = configurationCache[item.id] { - cell.contentConfiguration = cachedConfig - } else { - let config = UIHostingConfiguration { - cellBuilder(item) - } - .margins(.all, 0) - - configurationCache[item.id] = config - cell.contentConfiguration = config - } - } else { - // Non-cache mode: traditional behavior - cell.configure(with: cellBuilder(item)) - } - + cell.configure(with: cellBuilder(item)) return cell } @@ -338,27 +296,20 @@ public struct TiledView: UIViewRepre let onPrepend: (@MainActor () async throws -> Void)? @Binding var scrollPosition: TiledScrollPosition - /// Whether to cache UIHostingConfiguration to preserve SwiftUI @State. - /// When enabled, each Item gets a persistent configuration, maintaining State across cell reuse. - /// When disabled (default), configurations are recreated on each cell reuse for better memory efficiency. - let cachesCellState: Bool - public init( dataSource: ListDataSource, scrollPosition: Binding, - cachesCellState: Bool = false, onPrepend: (@MainActor () async throws -> Void)? = nil, @ViewBuilder cellBuilder: @escaping (Item) -> Cell ) { self.dataSource = dataSource self._scrollPosition = scrollPosition - self.cachesCellState = cachesCellState self.onPrepend = onPrepend self.cellBuilder = cellBuilder } public func makeUIView(context: Context) -> _TiledView { - let view = _TiledView(cellBuilder: cellBuilder, cachesCellState: cachesCellState, onPrepend: onPrepend) + let view = _TiledView(cellBuilder: cellBuilder, onPrepend: onPrepend) view.applyDataSource(dataSource) return view } From cb90e05a868e77bdcdfb666fa95879284bd99b70 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 18:46:30 +0900 Subject: [PATCH 25/27] Update --- Dev/MessagingUIDevelopment/Cell.swift | 31 ++-- Dev/MessagingUIDevelopment/ContentView.swift | 11 +- .../TiledViewDemo.swift | 133 ++++++++++-------- .../Tiled/TiledScrollPosition.swift | 6 +- Sources/MessagingUI/Tiled/TiledView.swift | 64 ++++----- 5 files changed, 138 insertions(+), 107 deletions(-) diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift index fb95edd..9d013d6 100644 --- a/Dev/MessagingUIDevelopment/Cell.swift +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -10,16 +10,16 @@ struct ChatMessage: Identifiable, Hashable, Equatable, Sendable { func generateSampleMessages(count: Int, startId: Int) -> [ChatMessage] { let sampleTexts: [String] = [ - "こんにちは!", - "今日はいい天気ですね。散歩に行きませんか?", - "昨日の映画、すごく面白かったです!特にラストシーンが印象的でした。もう一度観たいなと思っています。", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt", - "了解です👍", - "ちょっと待ってください。確認してから返信しますね。", - "週末の予定はどうですか?もし空いていたら、一緒にカフェでも行きませんか?新しくオープンしたお店があるんですよ。", + "Hello!", + "Nice weather today. Want to go for a walk?", + "The movie yesterday was amazing! The ending scene was so impressive. I'd love to watch it again.", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.", + "Got it 👍", + "Hold on, let me check and get back to you.", + "Any plans for the weekend? If you're free, want to grab coffee? There's a new place that just opened.", "OK", - "今から出発します!", - "長いメッセージのテストです。Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "On my way!", + "This is a long message for testing. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.", "🎉🎊✨", ] @@ -64,6 +64,12 @@ struct ChatBubbleView: View { .foregroundStyle(counter > 0 ? .white : .secondary) } .buttonStyle(.plain) + + Button("Expand") { + withAnimation(.smooth) { + isExpanded.toggle() + } + } } Text(message.text) @@ -85,12 +91,7 @@ struct ChatBubbleView: View { Spacer(minLength: 44) } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.snappy) { - isExpanded.toggle() - } - } + .contentShape(Rectangle()) .padding(.horizontal, 16) .padding(.vertical, 4) } diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 50c65c0..c50b43a 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -16,6 +16,8 @@ enum DemoDestination: Hashable { struct ContentView: View { + @Namespace private var namespace + var body: some View { NavigationStack { List { @@ -66,7 +68,7 @@ struct ContentView: View { .navigationDestination(for: DemoDestination.self) { destination in switch destination { case .tiledView: - BookTiledView() + BookTiledView(namespace: namespace) .navigationTitle("TiledView") .navigationBarTitleDisplayMode(.inline) case .applyDiffDemo: @@ -79,7 +81,12 @@ struct ContentView: View { } } .navigationDestination(for: ChatMessage.self) { message in - Text("Detail View for Message ID: \(message.id)") + if #available(iOS 18.0, *) { + Text("Detail View for Message ID: \(message.id)") + .navigationTransition(.zoom(sourceID: message.id, in: namespace)) + } else { + Text("Detail View for Message ID: \(message.id)") + } } } } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index 63bd691..a3e6023 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -120,20 +120,36 @@ struct BookTiledView: View { @State private var nextAppendId = 0 @State private var scrollPosition = TiledScrollPosition() - // TiledView options - @State private var cachesCellState = false + let namespace: Namespace.ID + + @ViewBuilder + private var tiledView: some View { + if #available(iOS 18.0, *) { + TiledView( + dataSource: dataSource, + scrollPosition: $scrollPosition, + cellBuilder: { message in + NavigationLink(value: message) { + ChatBubbleView(message: message) + .matchedTransitionSource(id: message.id, in: namespace) + } + } + ) + } else { + TiledView( + dataSource: dataSource, + scrollPosition: $scrollPosition, + cellBuilder: { message in + NavigationLink(value: message) { + ChatBubbleView(message: message) + } + } + ) + } + } var body: some View { - TiledView( - dataSource: dataSource, - scrollPosition: $scrollPosition, - cachesCellState: cachesCellState, - cellBuilder: { message in - NavigationLink(value: message) { - ChatBubbleView(message: message) - } - } - ) + tiledView .safeAreaInset(edge: .bottom) { VStack(spacing: 0) { Divider() @@ -143,10 +159,6 @@ struct BookTiledView: View { .foregroundStyle(.secondary) Spacer() HStack(spacing: 12) { - Toggle("Cache State", isOn: $cachesCellState) - .font(.caption) - .toggleStyle(.switch) - .controlSize(.mini) Text("v\(dataSource.changeCounter)") .font(.caption2) .foregroundStyle(.tertiary) @@ -177,46 +189,15 @@ struct BookTiledView: View { Image(systemName: "arrow.down.doc") } - // Insert at middle - Button { - let middleIndex = dataSource.items.count / 2 - let message = ChatMessage(id: nextAppendId, text: "Inserted at \(middleIndex)") - dataSource.insert([message], at: middleIndex) - nextAppendId += 1 - } label: { - Image(systemName: "arrow.right.doc.on.clipboard") - } - Spacer() - // Update - Button { - if var item = dataSource.items.first(where: { $0.id == 5 }) { - item.isExpanded.toggle() - item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" - dataSource.update([item]) - } - } label: { - Image(systemName: "pencil") - } - - // Remove - Button { - dataSource.remove(id: 10) - } label: { - Image(systemName: "trash") - } - - Spacer() - - // Scroll to Top + // Scroll Button { scrollPosition.scrollTo(edge: .top) } label: { Image(systemName: "arrow.up.to.line") } - // Scroll to Bottom Button { scrollPosition.scrollTo(edge: .bottom) } label: { @@ -225,14 +206,45 @@ struct BookTiledView: View { Spacer() - // Reset - Button { - nextPrependId = -1 - nextAppendId = 5 - let newItems = generateSampleMessages(count: 5, startId: 0) - dataSource.setItems(newItems) + // More actions + Menu { + Button { + let middleIndex = dataSource.items.count / 2 + let message = ChatMessage(id: nextAppendId, text: "Inserted at \(middleIndex)") + dataSource.insert([message], at: middleIndex) + nextAppendId += 1 + } label: { + Label("Insert at middle", systemImage: "arrow.right.doc.on.clipboard") + } + + Button { + if var item = dataSource.items.first(where: { $0.id == 5 }) { + item.isExpanded.toggle() + item.text = item.isExpanded ? "UPDATED & EXPANDED!" : "Updated back" + dataSource.update([item]) + } + } label: { + Label("Update ID:5", systemImage: "pencil") + } + + Button(role: .destructive) { + dataSource.remove(id: 10) + } label: { + Label("Remove ID:10", systemImage: "trash") + } + + Divider() + + Button { + nextPrependId = -1 + nextAppendId = 5 + let newItems = generateSampleMessages(count: 5, startId: 0) + dataSource.setItems(newItems) + } label: { + Label("Reset", systemImage: "arrow.counterclockwise") + } } label: { - Image(systemName: "arrow.counterclockwise") + Image(systemName: "ellipsis.circle") } } } @@ -240,7 +252,16 @@ struct BookTiledView: View { } #Preview("TiledView (UICollectionView)") { + @Previewable @Namespace var namespace NavigationStack { - BookTiledView() + BookTiledView(namespace: namespace) + .navigationDestination(for: ChatMessage.self) { message in + if #available(iOS 18.0, *) { + Text("Detail View for Message ID: \(message.id)") + .navigationTransition(.zoom(sourceID: message.id, in: namespace)) + } else { + Text("Detail View for Message ID: \(message.id)") + } + } } } diff --git a/Sources/MessagingUI/Tiled/TiledScrollPosition.swift b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift index a7eb88c..5b4b115 100644 --- a/Sources/MessagingUI/Tiled/TiledScrollPosition.swift +++ b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift @@ -18,6 +18,10 @@ public struct TiledScrollPosition: Equatable, Sendable { public mutating func scrollTo(edge: Edge, animated: Bool = true) { self.edge = edge self.animated = animated - self.version += 1 + makeDirty() + } + + private mutating func makeDirty() { + self.version &+= 1 } } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index f4b81ab..01aa2c6 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -56,8 +56,8 @@ public final class TiledViewCell: UICollectionViewCell { public final class _TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { - private unowned var collectionView: UICollectionView! - private unowned var tiledLayout: TiledCollectionViewLayout! + private let tiledLayout: TiledCollectionViewLayout = .init() + private var collectionView: UICollectionView! private var items: [Item] = [] private let cellBuilder: (Item) -> Cell @@ -88,40 +88,38 @@ public final class _TiledView: UIVie self.cellBuilder = cellBuilder self.onPrepend = onPrepend super.init(frame: .zero) - setupCollectionView() + + do { + tiledLayout.itemSizeProvider = { [weak self] index, width in + self?.measureSize(at: index, width: width) + } + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: tiledLayout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.selfSizingInvalidation = .enabledIncludingConstraints + collectionView.backgroundColor = .systemBackground + collectionView.allowsSelection = true + collectionView.dataSource = self + collectionView.delegate = self + collectionView.alwaysBounceVertical = true + + collectionView.register(TiledViewCell.self, forCellWithReuseIdentifier: TiledViewCell.reuseIdentifier) + + addSubview(collectionView) + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - private func setupCollectionView() { - tiledLayout = TiledCollectionViewLayout() - tiledLayout.itemSizeProvider = { [weak self] index, width in - self?.measureSize(at: index, width: width) - } - - collectionView = UICollectionView(frame: .zero, collectionViewLayout: tiledLayout) - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.selfSizingInvalidation = .enabledIncludingConstraints - collectionView.backgroundColor = .systemBackground - collectionView.allowsSelection = true - collectionView.dataSource = self - collectionView.delegate = self - collectionView.alwaysBounceVertical = true - - collectionView.register(TiledViewCell.self, forCellWithReuseIdentifier: TiledViewCell.reuseIdentifier) - - addSubview(collectionView) - - NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: topAnchor), - collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - + private func measureSize(at index: Int, width: CGFloat) -> CGSize? { guard index < items.count else { return nil } let item = items[index] @@ -153,7 +151,7 @@ public final class _TiledView: UIVie lastDataSourceID = dataSource.id appliedCursor = 0 tiledLayout.clear() - items = [] + items.removeAll() } // Apply only changes after the cursor @@ -261,7 +259,7 @@ public final class _TiledView: UIVie // MARK: - Scroll Position func applyScrollPosition(_ position: TiledScrollPosition) { - guard position.version != lastAppliedScrollVersion else { return } + guard position.version > lastAppliedScrollVersion else { return } lastAppliedScrollVersion = position.version guard let edge = position.edge else { return } From aaa6e5f4b8a60f168f1199ebfc2dd12c5e948192 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 19:09:50 +0900 Subject: [PATCH 26/27] Update --- Sources/MessagingUI/ListDataSource.swift | 28 ++-- .../Tiled/TiledCollectionViewLayout.swift | 149 +++++++++++------- Sources/MessagingUI/Tiled/TiledView.swift | 2 +- 3 files changed, 109 insertions(+), 70 deletions(-) diff --git a/Sources/MessagingUI/ListDataSource.swift b/Sources/MessagingUI/ListDataSource.swift index 807c3e3..6f70647 100644 --- a/Sources/MessagingUI/ListDataSource.swift +++ b/Sources/MessagingUI/ListDataSource.swift @@ -37,11 +37,9 @@ public struct ListDataSource: Equatable { /// Change counter used as cursor for tracking applied changes. public private(set) var changeCounter: Int = 0 - /// Internal storage using Deque for efficient prepend operations. - private var _items: Deque = [] - /// The current items in the data source. - public var items: [Item] { Array(_items) } + /// Uses Deque for efficient prepend/append operations. + public private(set) var items: Deque = [] /// Pending changes that haven't been consumed by TiledView yet. internal private(set) var pendingChanges: [Change] = [] @@ -51,7 +49,7 @@ public struct ListDataSource: Equatable { public init() {} public init(items: [Item]) { - self._items = Deque(items) + self.items = Deque(items) self.pendingChanges = [.setItems] self.changeCounter = 1 } @@ -61,7 +59,7 @@ public struct ListDataSource: Equatable { /// Sets all items, replacing any existing items. /// Use this for initial load or complete refresh. public mutating func setItems(_ items: [Item]) { - self._items = Deque(items) + self.items = Deque(items) pendingChanges.append(.setItems) changeCounter += 1 } @@ -72,7 +70,7 @@ public struct ListDataSource: Equatable { guard !items.isEmpty else { return } let ids = items.map { $0.id } for item in items.reversed() { - self._items.prepend(item) + self.items.prepend(item) } pendingChanges.append(.prepend(ids)) changeCounter += 1 @@ -83,7 +81,7 @@ public struct ListDataSource: Equatable { public mutating func append(_ items: [Item]) { guard !items.isEmpty else { return } let ids = items.map { $0.id } - self._items.append(contentsOf: items) + self.items.append(contentsOf: items) pendingChanges.append(.append(ids)) changeCounter += 1 } @@ -94,7 +92,7 @@ public struct ListDataSource: Equatable { guard !items.isEmpty else { return } let ids = items.map { $0.id } for (offset, item) in items.enumerated() { - self._items.insert(item, at: index + offset) + self.items.insert(item, at: index + offset) } pendingChanges.append(.insert(at: index, ids: ids)) changeCounter += 1 @@ -106,8 +104,8 @@ public struct ListDataSource: Equatable { guard !items.isEmpty else { return } var updatedIds: [Item.ID] = [] for item in items { - if let index = self._items.firstIndex(where: { $0.id == item.id }) { - self._items[index] = item + if let index = self.items.firstIndex(where: { $0.id == item.id }) { + self.items[index] = item updatedIds.append(item.id) } } @@ -121,8 +119,8 @@ public struct ListDataSource: Equatable { public mutating func remove(ids: [Item.ID]) { guard !ids.isEmpty else { return } let idsSet = Set(ids) - let removedIds = _items.filter { idsSet.contains($0.id) }.map { $0.id } - self._items.removeAll { idsSet.contains($0.id) } + let removedIds = items.filter { idsSet.contains($0.id) }.map { $0.id } + self.items.removeAll { idsSet.contains($0.id) } if !removedIds.isEmpty { pendingChanges.append(.remove(removedIds)) changeCounter += 1 @@ -148,8 +146,8 @@ extension ListDataSource { /// Removes items with the specified IDs (optimized for Hashable IDs). public mutating func remove(ids: Set) { guard !ids.isEmpty else { return } - let removedIds = _items.filter { ids.contains($0.id) }.map { $0.id } - self._items.removeAll { ids.contains($0.id) } + let removedIds = items.filter { ids.contains($0.id) }.map { $0.id } + self.items.removeAll { ids.contains($0.id) } if !removedIds.isEmpty { pendingChanges.append(.remove(removedIds)) changeCounter += 1 diff --git a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift index 9fe4e01..de72c71 100644 --- a/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -14,10 +14,6 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { // MARK: - Configuration - /// Enables caching of layout attributes for better scroll performance. - /// When enabled, attributes are reused instead of being recreated on each prepare() call. - public var usesAttributesCache: Bool = false - /// Closure to query item size. Receives index and width, returns size. /// If nil is returned, estimatedHeight will be used. public var itemSizeProvider: ((_ index: Int, _ width: CGFloat) -> CGSize?)? @@ -30,11 +26,8 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { // MARK: - Private State - private var attributesCache: [IndexPath: UICollectionViewLayoutAttributes] = [:] private var itemYPositions: Deque = [] private var itemHeights: Deque = [] - private var lastPreparedBoundsSize: CGSize = .zero - private var needsFullAttributesRebuild: Bool = true // MARK: - UICollectionViewLayout Overrides @@ -46,65 +39,57 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { } public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - collectionView?.bounds.size != newBounds.size + collectionView?.bounds.size.width != newBounds.size.width } public override func prepare() { guard let collectionView else { return } - - // Automatically update contentInset collectionView.contentInset = calculateContentInset() + } - let boundsSize = collectionView.bounds.size - let itemCount = collectionView.numberOfItems(inSection: 0) - - // Check if we need to rebuild attributes - let boundsSizeChanged = lastPreparedBoundsSize != boundsSize - let shouldRebuild = !usesAttributesCache || needsFullAttributesRebuild || boundsSizeChanged + public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard !itemYPositions.isEmpty else { return nil } - if shouldRebuild { - attributesCache.removeAll(keepingCapacity: usesAttributesCache) - lastPreparedBoundsSize = boundsSize + let boundsWidth = collectionView?.bounds.width ?? 0 - for index in 0.. rect.maxY { + break } - // Remove stale entries if item count decreased - if attributesCache.count > itemCount { - attributesCache = attributesCache.filter { $0.key.item < itemCount } + let frame = CGRect(x: 0, y: y, width: boundsWidth, height: height) + if frame.intersects(rect) { + let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0)) + attributes.frame = frame + result.append(attributes) } } - } - public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - attributesCache.values.filter { $0.frame.intersects(rect) } + return result } public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - attributesCache[indexPath] + + let index = indexPath.item + + guard index >= 0, index < itemYPositions.count else { + return nil + } + + let boundsWidth = collectionView?.bounds.width ?? 0 + let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) + attributes.frame = makeFrame(at: index, boundsWidth: boundsWidth) + return attributes } // MARK: - Self-Sizing Support @@ -120,6 +105,7 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes ) -> UICollectionViewLayoutInvalidationContext { + let context = super.invalidationContext( forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes @@ -196,8 +182,6 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { for i in (index + count)..= rect.minY. + /// Items before this index are completely above the visible area. + /// + /// Example: + /// ``` + /// items: [0] [1] [2] [3] [4] + /// bottom: 100 250 400 550 700 + /// rect.minY = 300 + /// + /// Result: index 2 (first item with bottom >= 300) + /// ``` + /// + /// Complexity: O(log n) instead of O(n) linear search. + private func findFirstVisibleIndex(in rect: CGRect) -> Int { + var low = 0 + var high = itemYPositions.count + + while low < high { + let mid = (low + high) / 2 + let itemBottom = itemYPositions[mid] + itemHeights[mid] + + if itemBottom < rect.minY { + // Item is completely above visible area, search in right half + low = mid + 1 + } else { + // Item may be visible or below, search in left half + high = mid + } + } + + return low + } + private func contentBounds() -> (top: CGFloat, bottom: CGFloat)? { guard let firstY = itemYPositions.first, let lastY = itemYPositions.last, @@ -263,6 +278,32 @@ public final class TiledCollectionViewLayout: UICollectionViewLayout { return (firstY, lastY + lastHeight) } + // MARK: - Debug Info + + /// Debug information about remaining scroll capacity. + public struct DebugCapacityInfo { + /// Remaining scroll space above the first item (in points). + public let topCapacity: CGFloat + /// Remaining scroll space below the last item (in points). + public let bottomCapacity: CGFloat + /// Total virtual content height. + public let virtualHeight: CGFloat + /// Anchor Y position (center point). + public let anchorY: CGFloat + } + + /// Returns debug information about remaining scroll capacity. + /// Useful for monitoring how much virtual space remains for prepend/append operations. + public var debugCapacityInfo: DebugCapacityInfo? { + guard let bounds = contentBounds() else { return nil } + return DebugCapacityInfo( + topCapacity: bounds.top, + bottomCapacity: virtualContentHeight - bounds.bottom, + virtualHeight: virtualContentHeight, + anchorY: anchorY + ) + } + private func calculateContentInset() -> UIEdgeInsets { guard let bounds = contentBounds() else { return .zero } diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index 01aa2c6..ee63fb9 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -169,7 +169,7 @@ public final class _TiledView: UIVie switch change { case .setItems: tiledLayout.clear() - items = dataSource.items + items = Array(dataSource.items) tiledLayout.appendItems(count: items.count, startingIndex: 0) collectionView.reloadData() From 4aedc566e484860995ca8ce3cd2dae6a243b2385 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 14 Dec 2025 19:22:46 +0900 Subject: [PATCH 27/27] Update --- .../ApplyDiffDemo.swift | 2 +- .../SwiftDataMemoDemo.swift | 2 +- .../TiledViewDemo.swift | 6 +- Sources/MessagingUI/Tiled/CellState.swift | 57 ++++++++++ Sources/MessagingUI/Tiled/TiledView.swift | 100 ++++++++++++++++-- 5 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 Sources/MessagingUI/Tiled/CellState.swift diff --git a/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift index aefb207..d29c8f5 100644 --- a/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift +++ b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift @@ -184,7 +184,7 @@ struct BookApplyDiffDemo: View { TiledView( dataSource: dataSource, scrollPosition: $scrollPosition, - cellBuilder: { message in + cellBuilder: { message, _ in ChatBubbleView(message: message) } ) diff --git a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift index 138a570..4dcf00d 100644 --- a/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -248,7 +248,7 @@ struct SwiftDataMemoDemo: View { onPrepend: { store.loadMore() }, - cellBuilder: { item in + cellBuilder: { item, _ in MemoBubbleView(item: item) { store.deleteMemo(id: item.id) } diff --git a/Dev/MessagingUIDevelopment/TiledViewDemo.swift b/Dev/MessagingUIDevelopment/TiledViewDemo.swift index a3e6023..8ff3992 100644 --- a/Dev/MessagingUIDevelopment/TiledViewDemo.swift +++ b/Dev/MessagingUIDevelopment/TiledViewDemo.swift @@ -128,7 +128,7 @@ struct BookTiledView: View { TiledView( dataSource: dataSource, scrollPosition: $scrollPosition, - cellBuilder: { message in + cellBuilder: { message, _ in NavigationLink(value: message) { ChatBubbleView(message: message) .matchedTransitionSource(id: message.id, in: namespace) @@ -139,13 +139,13 @@ struct BookTiledView: View { TiledView( dataSource: dataSource, scrollPosition: $scrollPosition, - cellBuilder: { message in + cellBuilder: { message, _ in NavigationLink(value: message) { ChatBubbleView(message: message) } } ) - } + } } var body: some View { diff --git a/Sources/MessagingUI/Tiled/CellState.swift b/Sources/MessagingUI/Tiled/CellState.swift new file mode 100644 index 0000000..ab8d42c --- /dev/null +++ b/Sources/MessagingUI/Tiled/CellState.swift @@ -0,0 +1,57 @@ +// +// CellState.swift +// MessagingUI +// +// Created by Hiroshi Kimura on 2025/12/14. +// + +import Foundation + +// MARK: - CustomStateKey + +/// A type-safe key for custom cell state. +/// +/// Define your custom state keys by conforming to this protocol: +/// ```swift +/// enum IsExpandedKey: CustomStateKey { +/// typealias Value = Bool +/// static var defaultValue: Bool { false } +/// } +/// ``` +/// +/// Then extend CellState for convenient access: +/// ```swift +/// extension CellState { +/// var isExpanded: Bool { +/// get { self[IsExpandedKey.self] } +/// set { self[IsExpandedKey.self] = newValue } +/// } +/// } +/// ``` +public protocol CustomStateKey { + associatedtype Value + static var defaultValue: Value { get } +} + +// MARK: - CellState + +/// Per-cell state storage using type-safe keys. +/// +/// CellState provides a flexible way to store arbitrary state for each cell +/// without modifying the data model. State is managed by the list view and +/// passed to cells during configuration. +public struct CellState { + + /// An empty cell state instance. + public static var empty: CellState { .init() } + + private var stateMap: [AnyKeyPath: Any] = [:] + + public init() {} + + /// Access state values using type-safe keys. + public subscript(key: T.Type) -> T.Value { + get { stateMap[\T.self] as? T.Value ?? T.defaultValue } + set { stateMap[\T.self] = newValue } + } +} diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift index ee63fb9..65d1f32 100644 --- a/Sources/MessagingUI/Tiled/TiledView.swift +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -14,6 +14,13 @@ public final class TiledViewCell: UICollectionViewCell { public static let reuseIdentifier = "TiledViewCell" + /// Custom state for this cell + public internal(set) var customState: CellState = .empty + + /// Handler called when state changes to update content + public var _updateConfigurationHandler: + @MainActor (TiledViewCell, CellState) -> Void = { _, _ in } + public func configure(with content: Content) { contentConfiguration = UIHostingConfiguration { content @@ -21,9 +28,17 @@ public final class TiledViewCell: UICollectionViewCell { .margins(.all, 0) } + /// Update cell content with new state + public func updateContent(using customState: CellState) { + self.customState = customState + _updateConfigurationHandler(self, customState) + } + public override func prepareForReuse() { super.prepareForReuse() contentConfiguration = nil + customState = .empty + _updateConfigurationHandler = { _, _ in } } public override func preferredLayoutAttributesFitting( @@ -60,7 +75,7 @@ public final class _TiledView: UIVie private var collectionView: UICollectionView! private var items: [Item] = [] - private let cellBuilder: (Item) -> Cell + private let cellBuilder: (Item, CellState) -> Cell /// prototype cell for size measurement private let sizingCell = TiledViewCell() @@ -77,12 +92,15 @@ public final class _TiledView: UIVie /// Scroll position tracking private var lastAppliedScrollVersion: UInt = 0 + /// Per-item cell state storage + private var stateMap: [Item.ID: CellState] = [:] + public typealias DataSource = ListDataSource public let onPrepend: (@MainActor () async throws -> Void)? public init( - cellBuilder: @escaping (Item) -> Cell, + cellBuilder: @escaping (Item, CellState) -> Cell, onPrepend: (@MainActor () async throws -> Void)? = nil ) { self.cellBuilder = cellBuilder @@ -123,9 +141,10 @@ public final class _TiledView: UIVie private func measureSize(at index: Int, width: CGFloat) -> CGSize? { guard index < items.count else { return nil } let item = items[index] + let state = stateMap[item.id] ?? .empty - // UIHostingConfigurationと同じ方法で計測 - sizingCell.configure(with: cellBuilder(item)) + // Measure using the same UIHostingConfiguration approach + sizingCell.configure(with: cellBuilder(item, state)) sizingCell.layoutIfNeeded() let targetSize = CGSize( @@ -228,7 +247,15 @@ public final class _TiledView: UIVie public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TiledViewCell.reuseIdentifier, for: indexPath) as! TiledViewCell let item = items[indexPath.item] - cell.configure(with: cellBuilder(item)) + let state = stateMap[item.id] ?? .empty + + cell.configure(with: cellBuilder(item, state)) + cell.customState = state + cell._updateConfigurationHandler = { [weak self] cell, newState in + guard let self else { return } + cell.configure(with: self.cellBuilder(item, newState)) + } + return cell } @@ -280,6 +307,55 @@ public final class _TiledView: UIVie animated: position.animated ) } + collectionView.flashScrollIndicators() + } + + // MARK: - Cell State Management + + /// Sets the entire CellState for an item (internal use) + func _setState(cellState: CellState, for itemId: Item.ID) { + stateMap[itemId] = cellState + + // Update visible cell if exists + if let index = items.firstIndex(where: { $0.id == itemId }), + let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) + as? TiledViewCell { + cell.updateContent(using: cellState) + } + } + + /// Sets an individual state value for an item + public func setState( + _ value: Key.Value, + key: Key.Type, + for itemId: Item.ID + ) { + var state = stateMap[itemId, default: .empty] + state[Key.self] = value + stateMap[itemId] = state + + if let index = items.firstIndex(where: { $0.id == itemId }), + let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) + as? TiledViewCell { + cell.updateContent(using: state) + } + } + + /// Gets a state value for an item + public func state(for itemId: Item.ID, key: Key.Type) -> Key.Value { + stateMap[itemId]?[Key.self] ?? Key.defaultValue + } + + /// Resets all cell states + public func resetState() { + stateMap.removeAll() + + for cell in collectionView.visibleCells { + if let tiledCell = cell as? TiledViewCell { + tiledCell.customState = .empty + tiledCell.updateContent(using: .empty) + } + } } } @@ -290,18 +366,21 @@ public struct TiledView: UIViewRepre public typealias UIViewType = _TiledView let dataSource: ListDataSource - let cellBuilder: (Item) -> Cell + let cellBuilder: (Item, CellState) -> Cell + let cellStates: [Item.ID: CellState]? let onPrepend: (@MainActor () async throws -> Void)? @Binding var scrollPosition: TiledScrollPosition public init( dataSource: ListDataSource, scrollPosition: Binding, + cellStates: [Item.ID: CellState]? = nil, onPrepend: (@MainActor () async throws -> Void)? = nil, - @ViewBuilder cellBuilder: @escaping (Item) -> Cell + @ViewBuilder cellBuilder: @escaping (Item, CellState) -> Cell ) { self.dataSource = dataSource self._scrollPosition = scrollPosition + self.cellStates = cellStates self.onPrepend = onPrepend self.cellBuilder = cellBuilder } @@ -315,5 +394,12 @@ public struct TiledView: UIViewRepre public func updateUIView(_ uiView: _TiledView, context: Context) { uiView.applyDataSource(dataSource) uiView.applyScrollPosition(scrollPosition) + + // Apply external cellStates if provided + if let cellStates { + for (id, state) in cellStates { + uiView._setState(cellState: state, for: id) + } + } } }