diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..26b8756 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "mcp__XcodeBuildMCP__discover_projs", + "mcp__sosumi__searchAppleDocumentation", + "WebFetch(domain:medium.com)", + "Bash(tee:*)" + ] + } +} 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 new file mode 100644 index 0000000..d29c8f5 --- /dev/null +++ b/Dev/MessagingUIDevelopment/ApplyDiffDemo.swift @@ -0,0 +1,221 @@ +// +// 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 + + @State private var scrollPosition = TiledScrollPosition() + + 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, + scrollPosition: $scrollPosition, + 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/BookChat.swift b/Dev/MessagingUIDevelopment/BookChat.swift deleted file mode 100644 index db756c2..0000000 --- a/Dev/MessagingUIDevelopment/BookChat.swift +++ /dev/null @@ -1,147 +0,0 @@ -import MessagingUI -import Foundation -import SwiftUI - -// MARK: - Previews - -private enum MessageSender { - case me - case other -} - -private struct PreviewMessage: Identifiable { - let id: UUID - let text: String - let sender: MessageSender - - init(id: UUID = UUID(), text: String, sender: MessageSender = .other) { - self.id = id - self.text = text - self.sender = sender - } -} - -struct MessageListPreviewContainer: View { - @State private var messages: [PreviewMessage] = [ - PreviewMessage(text: "Hello, how are you?", sender: .other), - PreviewMessage(text: "I'm fine, thank you!", sender: .me), - PreviewMessage(text: "What about you?", sender: .other), - PreviewMessage(text: "I'm doing great, thanks for asking!", sender: .me), - ] - @State private var isLoadingOlder = false - @State private var autoScrollToBottom = true - @State private var olderMessageCounter = 0 - @State private var newMessageCounter = 0 - - private static let sampleTexts = [ - "Hey, did you see that?", - "I totally agree with you", - "That's interesting!", - "Can you explain more?", - "I was thinking the same thing", - "Wow, really?", - "Let me check on that", - "Thanks for sharing", - "That makes sense", - "Good point!", - "I'll get back to you", - "Sounds good to me", - "Looking forward to it", - "Nice work!", - "Got it, thanks", - "Let's do this!", - "Perfect timing", - "I see what you mean", - "Absolutely!", - "That's amazing", - ] - - var body: some View { - VStack(spacing: 16) { - VStack(spacing: 8) { - Toggle("Auto-scroll to new messages", isOn: $autoScrollToBottom) - .font(.caption) - - Text("Scroll up to load older messages") - .font(.caption) - .foregroundStyle(.secondary) - } - - MessageList( - messages: messages, - autoScrollToBottom: $autoScrollToBottom, - onLoadOlderMessages: { - print("Loading older messages...") - try? await Task.sleep(for: .seconds(1)) - - // Add older messages at the beginning - // The scroll position will be automatically maintained - let newMessages = (0..<5).map { _ in - let randomText = Self.sampleTexts.randomElement() ?? "Message" - let sender: MessageSender = Bool.random() ? .me : .other - return PreviewMessage(text: randomText, sender: sender) - } - messages.insert(contentsOf: newMessages.reversed(), at: 0) - } - ) { message in - Text(message.text) - .padding(12) - .background(message.sender == .me ? Color.green.opacity(0.2) : Color.blue.opacity(0.1)) - .cornerRadius(8) - .frame(maxWidth: .infinity, alignment: message.sender == .me ? .trailing : .leading) - } - - HStack(spacing: 12) { - Button("Add New Message") { - let randomText = Self.sampleTexts.randomElement() ?? "Message" - let sender: MessageSender = Bool.random() ? .me : .other - messages.append(PreviewMessage(text: randomText, sender: sender)) - } - .buttonStyle(.borderedProminent) - - Button("Add Old Message") { - count += 1 - } - .buttonStyle(.bordered) - - Button("Clear All", role: .destructive) { - messages.removeAll() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding() - .task(id: count) { - let newMessages = (0..<10).map { _ in - let randomText = Self.sampleTexts.randomElement() ?? "Message" - let sender: MessageSender = Bool.random() ? .me : .other - return PreviewMessage(text: randomText, sender: sender) - } - try? await Task.sleep(for: .milliseconds(500)) - messages.insert(contentsOf: newMessages.reversed(), at: 0) - } - } - - @State var count: Int = 0 -} - -#Preview("Interactive Preview") { - MessageListPreviewContainer() -} - -#Preview("Simple Preview") { - MessageList(messages: [ - PreviewMessage(text: "Hello, how are you?", sender: .other), - PreviewMessage(text: "I'm fine, thank you!", sender: .me), - PreviewMessage(text: "What about you?", sender: .other), - PreviewMessage(text: "I'm doing great, thanks for asking!", sender: .me), - ]) { message in - Text(message.text) - .padding(12) - .background(message.sender == .me ? Color.green.opacity(0.2) : Color.blue.opacity(0.1)) - .cornerRadius(8) - .frame(maxWidth: .infinity, alignment: message.sender == .me ? .trailing : .leading) - } - .padding() -} diff --git a/Dev/MessagingUIDevelopment/Cell.swift b/Dev/MessagingUIDevelopment/Cell.swift new file mode 100644 index 0000000..9d013d6 --- /dev/null +++ b/Dev/MessagingUIDevelopment/Cell.swift @@ -0,0 +1,169 @@ +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: [String] = [ + "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", + "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.", + "๐ŸŽ‰๐ŸŽŠโœจ", + ] + + return (0.. 0 ? Color.blue : Color(.systemGray5)) + ) + .foregroundStyle(counter > 0 ? .white : .secondary) + } + .buttonStyle(.plain) + + Button("Expand") { + withAnimation(.smooth) { + isExpanded.toggle() + } + } + } + + Text(message.text) + .font(.system(size: 16)) + .fixedSize(horizontal: false, vertical: true) + + if isExpanded { + Text("Expanded (local @State)") + .font(.caption) + .foregroundStyle(.blue) + .padding(.top, 4) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + + Spacer(minLength: 44) + } + .contentShape(Rectangle()) + .padding(.horizontal, 16) + .padding(.vertical, 4) + } +} + +#Preview("SwiftUI Direct") { + ChatBubbleView( + message: .init( + id: 1, + text: "ๆ˜จๆ—ฅใฎๆ˜ ็”ปใ€ใ™ใ”ใ้ข็™ฝใ‹ใฃใŸใงใ™๏ผ็‰นใซใƒฉใ‚นใƒˆใ‚ทใƒผใƒณใŒๅฐ่ฑก็š„ใงใ—ใŸใ€‚ใ‚‚ใ†ไธ€ๅบฆ่ฆณใŸใ„ใชใจๆ€ใฃใฆใ„ใพใ™ใ€‚" + ) + ) +} +struct HostingControllerWrapper: UIViewControllerRepresentable { + let content: Content + + func makeUIViewController(context: Context) -> UIHostingController { + 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) + + 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.view.systemLayoutSizeFitting( + CGSize( + width: proposal.width ?? UIView.layoutFittingCompressedSize.width, + height: 1000 + ), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + print(size) + + return size + + + } +} +#Preview("UIHostingController") { + + @Previewable @State var size: CGSize = .zero + + VStack { + Text("Size: \(size.width) x \(size.height)") + ZStack { + HostingControllerWrapper( + content: + ChatBubbleView( + message: .init( + id: 1, + text: "ๆ˜จๆ—ฅใฎๆ˜ ็”ปใ€ใ™ใ”ใ้ข็™ฝใ‹ใฃใŸใงใ™๏ผ็‰นใซใƒฉใ‚นใƒˆใ‚ทใƒผใƒณใŒๅฐ่ฑก็š„ใงใ—ใŸใ€‚ใ‚‚ใ†ไธ€ๅบฆ่ฆณใŸใ„ใชใจๆ€ใฃใฆใ„ใพใ™ใ€‚" + ) + ) + ) + } + .background(.red) + .onGeometryChange(for: CGSize.self, of: \.size) { n in + size = n + } + } +} diff --git a/Dev/MessagingUIDevelopment/ContentView.swift b/Dev/MessagingUIDevelopment/ContentView.swift index 1d8a10a..c50b43a 100644 --- a/Dev/MessagingUIDevelopment/ContentView.swift +++ b/Dev/MessagingUIDevelopment/ContentView.swift @@ -6,13 +6,86 @@ // 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 { - Form { - NavigationLink("Message List Preview") { - MessageListPreviewContainer() + List { + Section("Demos") { + NavigationLink(value: DemoDestination.tiledView) { + Label { + VStack(alignment: .leading) { + Text("TiledView") + Text("UICollectionView based") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "square.grid.2x2") + } + } + + NavigationLink(value: DemoDestination.applyDiffDemo) { + Label { + VStack(alignment: .leading) { + Text("applyDiff Demo") + Text("Auto-detect array changes") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.triangle.2.circlepath") + } + } + } + + Section("SwiftData Integration") { + NavigationLink(value: DemoDestination.swiftDataMemo) { + Label { + VStack(alignment: .leading) { + Text("Memo Stream") + Text("SwiftData + TiledView pagination") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "note.text") + } + } + } + } + .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/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..4dcf00d --- /dev/null +++ b/Dev/MessagingUIDevelopment/SwiftDataMemoDemo.swift @@ -0,0 +1,312 @@ +// +// 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 + var onDelete: (() -> Void)? + + 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) + .contextMenu { + if let onDelete { + Button(role: .destructive) { + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } +} + +// MARK: - MemoStore (using applyDiff) + +@Observable +final class MemoStore { + + private let modelContext: ModelContext + private(set) var dataSource = ListDataSource() + private(set) var hasMore = true + + /// Current loaded item count for pagination + private var loadedCount = 0 + private let pageSize = 10 + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + /// 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() + } + + /// Fetch from SwiftData and apply diff + private func refreshFromDatabase() { + // 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)] // oldest to newest + ) + descriptor.fetchOffset = offset + descriptor.fetchLimit = loadedCount + + let memos = (try? modelContext.fetch(descriptor)) ?? [] + let items = memos.map(MemoItem.init) + + // 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() + + // 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?", + "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.. + @Binding var nextPrependId: Int + @Binding var nextAppendId: Int + + var body: 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() + + Button("Append 5") { + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + dataSource.append(messages) + nextAppendId += 5 + } + .buttonStyle(.bordered) + } + + // 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: 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 + nextAppendId = 5 + let newItems = generateSampleMessages(count: 5, startId: 0) + dataSource.setItems(newItems) + } + .buttonStyle(.borderedProminent) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Count: \(dataSource.items.count)") + .font(.caption) + Text("ChangeCounter: \(dataSource.changeCounter)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - TiledView Demo (UICollectionView) + +struct BookTiledView: View { + + @State private var dataSource = ListDataSource() + @State private var nextPrependId = -1 + @State private var nextAppendId = 0 + @State private var scrollPosition = TiledScrollPosition() + + 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 + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + HStack { + Text("\(dataSource.items.count) items") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + HStack(spacing: 12) { + Text("v\(dataSource.changeCounter)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(.bar) + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + // Prepend + Button { + let messages = generateSampleMessages(count: 5, startId: nextPrependId - 4) + dataSource.prepend(messages) + nextPrependId -= 5 + } label: { + Image(systemName: "arrow.up.doc") + } + + // Append + Button { + let messages = generateSampleMessages(count: 5, startId: nextAppendId) + dataSource.append(messages) + nextAppendId += 5 + } label: { + Image(systemName: "arrow.down.doc") + } + + Spacer() + + // Scroll + Button { + scrollPosition.scrollTo(edge: .top) + } label: { + Image(systemName: "arrow.up.to.line") + } + + Button { + scrollPosition.scrollTo(edge: .bottom) + } label: { + Image(systemName: "arrow.down.to.line") + } + + Spacer() + + // 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: "ellipsis.circle") + } + } + } + } +} + +#Preview("TiledView (UICollectionView)") { + @Previewable @Namespace var namespace + 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)") + } + } + } +} diff --git a/Package.resolved b/Package.resolved index 3c81661..d6d5508 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "ae75b6c63df44fdc6ac5ec3d338d27ea8de305cd24d358a5910dd2172af06d32", + "originHash" : "8d24494424a8ed4f5da16f114281a1bdc6c1bed126aebdb9164f67a5659e153b", "pins" : [ { - "identity" : "swiftui-introspect", + "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", + "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "a08b87f96b41055577721a6e397562b21ad52454", - "version" : "26.0.0" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } } ], diff --git a/Package.swift b/Package.swift index 8eeb2d6..95be862 100644 --- a/Package.swift +++ b/Package.swift @@ -15,13 +15,13 @@ 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") ] ), .testTarget( 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 572c8ea..0000000 --- a/Sources/MessagingUI/Internal/OlderMessagesLoadingModifier.swift +++ /dev/null @@ -1,211 +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 leadingScreens: CGFloat = 1.0 - - nonisolated init( - autoScrollToBottom: Binding?, - onLoadOlderMessages: (@MainActor () async -> Void)? - ) { - self.autoScrollToBottom = autoScrollToBottom - self.onLoadOlderMessages = onLoadOlderMessages - } - - func body(content: Content) -> some View { - if onLoadOlderMessages != nil { - if #available(iOS 18.0, macOS 15.0, *) { - content - .introspect(.scrollView, on: .iOS(.v18, .v26)) { scrollView in - // Save reference and setup monitoring - setupScrollPositionPreservation(scrollView: scrollView) - } - .onScrollGeometryChange(for: _GeometryInfo.self) { geometry in - return _GeometryInfo( - contentOffset: geometry.contentOffset, - contentSize: geometry.contentSize, - containerSize: geometry.containerSize - ) - } action: { _, geometry in - let triggers = shouldTriggerLoading( - contentOffset: geometry.contentOffset.y, - boundsHeight: geometry.containerSize.height, - contentHeight: geometry.contentSize.height - ) - - if triggers { - Task { @MainActor in - trigger() - } - } - } - } else { - content.introspect(.scrollView, on: .iOS(.v17)) { scrollView in - // Save reference and setup monitoring - setupScrollPositionPreservation(scrollView: scrollView) - - controller.scrollViewSubscription?.cancel() - - controller.scrollViewSubscription = scrollView.publisher( - for: \.contentOffset - ) - .sink { [weak scrollView] offset in - guard let scrollView else { return } - - let triggers = shouldTriggerLoading( - contentOffset: offset.y, - boundsHeight: scrollView.bounds.height, - contentHeight: scrollView.contentSize.height - ) - - if triggers { - Task { @MainActor in - trigger() - } - } - } - } - } - } else { - content - } - } - - private var isBackwardLoading: Bool { - controller.internalIsBackwardLoading - } - - private func setBackwardLoading(_ value: Bool) { - controller.internalIsBackwardLoading = value - } - - private func shouldTriggerLoading( - contentOffset: CGFloat, - boundsHeight: CGFloat, - contentHeight: CGFloat - ) -> Bool { - guard !isBackwardLoading else { return false } - guard controller.currentLoadingTask == nil else { return false } - - // Check scroll direction - guard let previousOffset = controller.previousContentOffset else { - // First time - can't determine direction, just save and skip - controller.previousContentOffset = contentOffset - return false - } - - let isScrollingUp = contentOffset < previousOffset - - // Update previous offset for next comparison - controller.previousContentOffset = contentOffset - - // Only trigger when scrolling up (towards older messages) - guard isScrollingUp else { - return false - } - - let triggerDistance = boundsHeight * leadingScreens - let distanceFromTop = contentOffset - - let shouldTrigger = distanceFromTop <= triggerDistance - - return shouldTrigger - } - - @MainActor - private func setupScrollPositionPreservation(scrollView: UIScrollView) { - - controller.scrollViewRef = scrollView - - // Clean up existing observations - controller.contentSizeObservation?.invalidate() - - // Monitor contentSize to detect when content is added (KVO) - controller.contentSizeObservation = scrollView.observe( - \.contentSize, - options: [.old, .new] - ) { scrollView, change in - MainActor.assumeIsolated { - guard let oldHeight = change.oldValue?.height else { return } - - let newHeight = scrollView.contentSize.height - let heightDiff = newHeight - oldHeight - - // Content size increased - if heightDiff > 0 { - let currentOffset = scrollView.contentOffset.y - let boundsHeight = scrollView.bounds.height - - // Case 1: Loading older messages โ†’ preserve scroll position (highest priority) - if isBackwardLoading { - let newOffset = currentOffset + heightDiff - scrollView.contentOffset.y = newOffset - } - // Case 2: autoScrollToBottom enabled โ†’ scroll to bottom - else if let autoScrollToBottom = autoScrollToBottom, - autoScrollToBottom.wrappedValue - { - let bottomOffset = newHeight - boundsHeight - UIView.animate(withDuration: 0.3) { - scrollView.contentOffset.y = max(0, bottomOffset) - } - } - // Case 3: Normal message addition โ†’ do nothing - } - } - } - } - - @MainActor - private func trigger() { - - guard let onLoadOlderMessages = onLoadOlderMessages else { return } - - guard !isBackwardLoading else { return } - - guard controller.currentLoadingTask == nil else { return } - - let task = Task { @MainActor in - await withTaskCancellationHandler { - setBackwardLoading(true) - - await onLoadOlderMessages() - - // Debounce to avoid rapid re-triggering - // Ensure the UI has time to update - try? await Task.sleep(for: .milliseconds(100)) - - setBackwardLoading(false) - - controller.currentLoadingTask = nil - } onCancel: { - Task { @MainActor in - setBackwardLoading(false) - controller.currentLoadingTask = nil - } - } - - } - - controller.currentLoadingTask = task - } -} - -// Helper struct for scroll geometry -struct _GeometryInfo: Equatable { - let contentOffset: CGPoint - let contentSize: CGSize - let containerSize: CGSize -} diff --git a/Sources/MessagingUI/ListDataSource.swift b/Sources/MessagingUI/ListDataSource.swift new file mode 100644 index 0000000..6f70647 --- /dev/null +++ b/Sources/MessagingUI/ListDataSource.swift @@ -0,0 +1,290 @@ +// +// ListDataSource.swift +// MessagingUI +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import DequeModule +import Foundation + +// MARK: - ListDataSource + +/// 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 list views to know exactly what changed +/// and update accordingly without adjusting content offset. +public struct ListDataSource: Equatable { + + // MARK: - Change + + public enum Change: 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]) + } + + // 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. + /// 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] = [] + + // MARK: - Initializers + + public init() {} + + public init(items: [Item]) { + self.items = Deque(items) + self.pendingChanges = [.setItems] + 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 = Deque(items) + pendingChanges.append(.setItems) + 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 } + let ids = items.map { $0.id } + for item in items.reversed() { + self.items.prepend(item) + } + pendingChanges.append(.prepend(ids)) + 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 } + let ids = items.map { $0.id } + self.items.append(contentsOf: items) + pendingChanges.append(.append(ids)) + 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]) { + 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 + updatedIds.append(item.id) + } + } + if !updatedIds.isEmpty { + pendingChanges.append(.update(updatedIds)) + 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: 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 + } +} + +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) } + if !removedIds.isEmpty { + pendingChanges.append(.remove(removedIds)) + 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 + +/// 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 deleted file mode 100644 index 8bdc622..0000000 --- a/Sources/MessagingUI/MessageList.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// MessageList.swift -// swiftui-messaging-ui -// -// Created by Hiroshi Kimura on 2025/10/23. -// - -import SwiftUI -import SwiftUIIntrospect -import Combine - -/// # Spec -/// -/// - `MessageList` is a generic, scrollable message list component that displays messages using a custom view builder. -/// - Keeps short lists anchored to the bottom of the scroll view. -/// - Supports loading older messages by scrolling up, with an optional loading indicator at the top. -/// -/// ## Usage -/// -/// ```swift -/// MessageList(messages: messages) { message in -/// Text(message.text) -/// .padding(12) -/// .background(Color.blue.opacity(0.1)) -/// .cornerRadius(8) -/// } -/// ``` -public struct MessageList: View { - - public let messages: [Message] - private let content: (Message) -> Content - private let autoScrollToBottom: Binding? - private let onLoadOlderMessages: (@MainActor () async -> Void)? - - /// Creates a simple message list without older message loading support. - /// - /// - Parameters: - /// - messages: Array of messages to display. Must conform to `Identifiable`. - /// - content: A view builder that creates the view for each message. - public init( - messages: [Message], - @ViewBuilder content: @escaping (Message) -> Content - ) { - self.messages = messages - self.content = content - self.autoScrollToBottom = nil - self.onLoadOlderMessages = nil - } - - /// Creates a message list with older message loading support. - /// - /// - Parameters: - /// - messages: Array of messages to display. Must conform to `Identifiable`. - /// - autoScrollToBottom: Optional binding that controls automatic scrolling to bottom when new messages are added. - /// - onLoadOlderMessages: Async closure called when user scrolls up to trigger loading older messages. - /// - content: A view builder that creates the view for each message. - public init( - messages: [Message], - autoScrollToBottom: Binding? = nil, - onLoadOlderMessages: @escaping @MainActor () async -> Void, - @ViewBuilder content: @escaping (Message) -> Content - ) { - self.messages = messages - self.content = content - self.autoScrollToBottom = autoScrollToBottom - self.onLoadOlderMessages = onLoadOlderMessages - } - - public var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 8) { - if onLoadOlderMessages != nil { - Section { - ForEach(messages) { message in - content(message) - .anchorPreference( - key: _VisibleMessagesPreference.self, - value: .bounds - ) { anchor in - [_VisibleMessagePayload(messageId: AnyHashable(message.id), bounds: anchor)] - } - } - } header: { - ProgressView() - .frame(height: 40) - } - } else { - ForEach(messages) { message in - content(message) - .anchorPreference( - key: _VisibleMessagesPreference.self, - value: .bounds - ) { anchor in - [_VisibleMessagePayload(messageId: AnyHashable(message.id), bounds: anchor)] - } - } - } - } - } - .overlayPreferenceValue(_VisibleMessagesPreference.self) { payloads in - GeometryReader { geometry in - let sorted = payloads - .map { payload in - let rect = geometry[payload.bounds] - return (id: payload.messageId, y: rect.minY) - } - .sorted { $0.y < $1.y } - - VStack(alignment: .leading, spacing: 4) { - Text("Visible Messages: \(sorted.count)") - .font(.caption) - .fontWeight(.bold) - - if let first = sorted.first { - Text("First: \(String(describing: first.id))") - .font(.caption2) - Text(" y=\(String(format: "%.1f", first.y))") - .font(.caption2) - .foregroundStyle(.secondary) - } - - if let last = sorted.last { - Text("Last: \(String(describing: last.id))") - .font(.caption2) - Text(" y=\(String(format: "%.1f", last.y))") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .padding(8) - .background(Color.black.opacity(0.8)) - .foregroundStyle(.white) - .cornerRadius(8) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding() - } - } - .modifier( - _OlderMessagesLoadingModifier( - autoScrollToBottom: autoScrollToBottom, - onLoadOlderMessages: onLoadOlderMessages - ) - ) - } - } - -} diff --git a/Sources/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/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..de72c71 --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledCollectionViewLayout.swift @@ -0,0 +1,320 @@ +// +// TiledCollectionViewLayout.swift +// TiledView +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import DequeModule +import UIKit + +// MARK: - TiledCollectionViewLayout + +public final class TiledCollectionViewLayout: UICollectionViewLayout { + + // MARK: - Configuration + + /// 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: - 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 itemYPositions: Deque = [] + private var itemHeights: Deque = [] + + // MARK: - UICollectionViewLayout Overrides + + public override var collectionViewContentSize: CGSize { + CGSize( + width: collectionView?.bounds.width ?? 0, + height: virtualContentHeight + ) + } + + public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + collectionView?.bounds.size.width != newBounds.size.width + } + + public override func prepare() { + guard let collectionView else { return } + collectionView.contentInset = calculateContentInset() + } + + public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard !itemYPositions.isEmpty else { return nil } + + let boundsWidth = collectionView?.bounds.width ?? 0 + + // Binary search for first visible item + let firstIndex = findFirstVisibleIndex(in: rect) + guard firstIndex < itemYPositions.count else { return nil } + + // Collect all visible items + var result: [UICollectionViewLayoutAttributes] = [] + for index in firstIndex.. rect.maxY { + break + } + + 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) + } + } + + return result + } + + public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + + 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 + + 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 + } + + // MARK: - Public Item Management API + + public func appendItems(count: Int, startingIndex: Int) { + let width = collectionView?.bounds.width ?? 0 + + 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..= 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).. CGRect { + CGRect( + x: 0, + y: itemYPositions[index], + width: boundsWidth, + height: itemHeights[index] + ) + } + + /// Binary search to find the first item that could be visible in the rect. + /// + /// Finds the smallest index where the item's bottom edge >= 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, + let lastHeight = itemHeights.last else { return nil } + 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 } + + let topInset = bounds.top + let bottomInset = virtualContentHeight - bounds.bottom + + return UIEdgeInsets( + top: -topInset, + left: 0, + bottom: -bottomInset, + right: 0 + ) + } +} diff --git a/Sources/MessagingUI/Tiled/TiledScrollPosition.swift b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift new file mode 100644 index 0000000..5b4b115 --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledScrollPosition.swift @@ -0,0 +1,27 @@ + +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 + makeDirty() + } + + private mutating func makeDirty() { + self.version &+= 1 + } +} diff --git a/Sources/MessagingUI/Tiled/TiledView.swift b/Sources/MessagingUI/Tiled/TiledView.swift new file mode 100644 index 0000000..65d1f32 --- /dev/null +++ b/Sources/MessagingUI/Tiled/TiledView.swift @@ -0,0 +1,405 @@ +// +// TiledView.swift +// TiledView +// +// Created by Hiroshi Kimura on 2025/12/10. +// + +import SwiftUI +import UIKit + +// MARK: - TiledViewCell + +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 + } + .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( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes + + // MagazineLayoutๆ–นๅผ: contentViewใฎๅน…ใ‚’layoutAttributesใจๅŒๆœŸ + if contentView.bounds.width != layoutAttributes.size.width { + contentView.bounds.size.width = layoutAttributes.size.width + } + + 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 + } +} + +// MARK: - _TiledView + +public final class _TiledView: UIView, UICollectionViewDataSource, UICollectionViewDelegate { + + private let tiledLayout: TiledCollectionViewLayout = .init() + private var collectionView: UICollectionView! + + private var items: [Item] = [] + private let cellBuilder: (Item, CellState) -> Cell + + /// prototype cell for size measurement + private let sizingCell = TiledViewCell() + + /// DataSource tracking + private var lastDataSourceID: UUID? + private var appliedCursor: Int = 0 + + /// Prepend trigger state + private var isPrependTriggered: Bool = false + private let prependThreshold: CGFloat = 100 + private var prependTask: Task? + + /// 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, CellState) -> Cell, + onPrepend: (@MainActor () async throws -> Void)? = nil + ) { + self.cellBuilder = cellBuilder + self.onPrepend = onPrepend + super.init(frame: .zero) + + 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 measureSize(at index: Int, width: CGFloat) -> CGSize? { + guard index < items.count else { return nil } + let item = items[index] + let state = stateMap[item.id] ?? .empty + + // Measure using the same UIHostingConfiguration approach + sizingCell.configure(with: cellBuilder(item, state)) + sizingCell.layoutIfNeeded() + + let targetSize = CGSize( + width: width, + height: UIView.layoutFittingCompressedSize.height + ) + + let size = sizingCell.contentView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + return size + } + + // MARK: - DataSource-based API + + /// Applies changes from a ListDataSource. + /// Uses cursor tracking to apply only new changes since last application. + public func applyDataSource(_ dataSource: ListDataSource) { + // Check if this is a new DataSource instance + if lastDataSourceID != dataSource.id { + lastDataSourceID = dataSource.id + appliedCursor = 0 + tiledLayout.clear() + items.removeAll() + } + + // 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, from: dataSource) + } + appliedCursor = pendingChanges.count + } + + private func applyChange(_ change: ListDataSource.Change, from dataSource: ListDataSource) { + switch change { + case .setItems: + tiledLayout.clear() + items = Array(dataSource.items) + tiledLayout.appendItems(count: items.count, startingIndex: 0) + collectionView.reloadData() + + 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 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 .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) + } + tiledLayout.insertItems(count: newItems.count, at: index) + collectionView.reloadData() + + 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() + + 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) } + tiledLayout.removeItems(at: indicesToRemove) + collectionView.reloadData() + } + } + + // 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] + 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 + } + + // MARK: UICollectionViewDelegate + + 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 && prependTask == nil { + isPrependTriggered = true + prependTask = Task { @MainActor [weak self] in + defer { self?.prependTask = nil } + try? await self?.onPrepend?() + } + } + } else { + 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 + ) + } + 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) + } + } + } +} + +// MARK: - TiledView + +public struct TiledView: UIViewRepresentable { + + public typealias UIViewType = _TiledView + + let dataSource: ListDataSource + 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, CellState) -> Cell + ) { + self.dataSource = dataSource + self._scrollPosition = scrollPosition + self.cellStates = cellStates + self.onPrepend = onPrepend + self.cellBuilder = cellBuilder + } + + public func makeUIView(context: Context) -> _TiledView { + let view = _TiledView(cellBuilder: cellBuilder, onPrepend: onPrepend) + view.applyDataSource(dataSource) + return view + } + + 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) + } + } + } +} 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) + } +} 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:))