Skip to content

Commit 0fcb766

Browse files
authored
Muukii/keyboard handing (#9)
1 parent 34bfc67 commit 0fcb766

File tree

9 files changed

+769
-154
lines changed

9 files changed

+769
-154
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"mcp__XcodeBuildMCP__discover_projs",
55
"mcp__sosumi__searchAppleDocumentation",
66
"WebFetch(domain:medium.com)",
7-
"Bash(tee:*)"
7+
"Bash(tee:*)",
8+
"Bash(xargs:*)"
89
]
910
}
1011
}

Dev/MessagingUIDevelopment/ContentView.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import MessagingUI
1010

1111
enum DemoDestination: Hashable {
1212
case tiledView
13+
case lazyVStack
1314
case iMessage
1415
case iMessageSwiftData
1516
case applyDiffDemo
@@ -37,6 +38,19 @@ struct ContentView: View {
3738
}
3839
}
3940

41+
NavigationLink(value: DemoDestination.lazyVStack) {
42+
Label {
43+
VStack(alignment: .leading) {
44+
Text("LazyVStack")
45+
Text("SwiftUI native")
46+
.font(.caption)
47+
.foregroundStyle(.secondary)
48+
}
49+
} icon: {
50+
Image(systemName: "list.bullet")
51+
}
52+
}
53+
4054
NavigationLink(value: DemoDestination.iMessage) {
4155
Label {
4256
VStack(alignment: .leading) {
@@ -99,6 +113,8 @@ struct ContentView: View {
99113
BookTiledView(namespace: namespace)
100114
.navigationTitle("TiledView")
101115
.navigationBarTitleDisplayMode(.inline)
116+
case .lazyVStack:
117+
LazyVStackDemo()
102118
case .iMessage:
103119
iMessageDemo()
104120
case .iMessageSwiftData:
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//
2+
// LazyVStackDemo.swift
3+
// MessagingUIDevelopment
4+
//
5+
// Created by Hiroshi Kimura on 2025/12/18.
6+
//
7+
8+
import SwiftUI
9+
10+
struct LazyVStackDemo: View {
11+
12+
struct Message: Identifiable {
13+
let id: UUID
14+
let text: String
15+
let isMe: Bool
16+
let timestamp: Date
17+
}
18+
19+
@State private var messages: [Message] = []
20+
@State private var inputText: String = ""
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
ScrollViewReader { proxy in
25+
ScrollView {
26+
LazyVStack(spacing: 8) {
27+
ForEach(messages) { message in
28+
MessageRow(message: message)
29+
.id(message.id)
30+
}
31+
}
32+
.padding(.horizontal, 16)
33+
.padding(.vertical, 8)
34+
}
35+
.onChange(of: messages.count) {
36+
if let last = messages.last {
37+
withAnimation {
38+
proxy.scrollTo(last.id, anchor: .bottom)
39+
}
40+
}
41+
}
42+
}
43+
44+
Divider()
45+
46+
HStack(spacing: 12) {
47+
TextField("Message", text: $inputText)
48+
.textFieldStyle(.roundedBorder)
49+
50+
Button {
51+
sendMessage()
52+
} label: {
53+
Image(systemName: "arrow.up.circle.fill")
54+
.font(.title)
55+
}
56+
.disabled(inputText.isEmpty)
57+
}
58+
.padding()
59+
}
60+
.navigationTitle("LazyVStack Demo")
61+
.toolbar {
62+
ToolbarItem(placement: .topBarTrailing) {
63+
Menu {
64+
Button("Add 10 messages") {
65+
generateMessages(count: 10)
66+
}
67+
Button("Add 100 messages") {
68+
generateMessages(count: 100)
69+
}
70+
Button("Add 1000 messages") {
71+
generateMessages(count: 1000)
72+
}
73+
Divider()
74+
Button("Clear", role: .destructive) {
75+
messages.removeAll()
76+
}
77+
} label: {
78+
Image(systemName: "ellipsis.circle")
79+
}
80+
}
81+
}
82+
.onAppear {
83+
generateMessages(count: 20)
84+
}
85+
}
86+
87+
private func sendMessage() {
88+
guard !inputText.isEmpty else { return }
89+
let message = Message(
90+
id: UUID(),
91+
text: inputText,
92+
isMe: true,
93+
timestamp: Date()
94+
)
95+
messages.append(message)
96+
inputText = ""
97+
}
98+
99+
private func generateMessages(count: Int) {
100+
let sampleTexts = [
101+
"Hello!",
102+
"How are you?",
103+
"I'm doing great, thanks for asking!",
104+
"What's up?",
105+
"Just working on some code.",
106+
"That sounds interesting. What kind of project?",
107+
"Building a messaging UI component for SwiftUI.",
108+
"Nice! SwiftUI is really powerful for building UIs.",
109+
"Yes, but there are some tricky parts with scroll performance.",
110+
"I've heard LazyVStack can have issues with large lists.",
111+
"That's why I'm exploring UICollectionView-based solutions.",
112+
"Makes sense. UIKit has more mature APIs for that.",
113+
]
114+
115+
for i in 0..<count {
116+
let message = Message(
117+
id: UUID(),
118+
text: sampleTexts[i % sampleTexts.count],
119+
isMe: i % 2 == 0,
120+
timestamp: Date().addingTimeInterval(Double(i) * -60)
121+
)
122+
messages.append(message)
123+
}
124+
}
125+
126+
struct MessageRow: View {
127+
let message: Message
128+
@State private var isExpanded: Bool = false
129+
@State private var isLiked: Bool = false
130+
131+
var body: some View {
132+
HStack {
133+
if message.isMe { Spacer(minLength: 60) }
134+
135+
VStack(alignment: message.isMe ? .trailing : .leading, spacing: 4) {
136+
Text(message.text)
137+
.padding(.horizontal, 12)
138+
.padding(.vertical, 8)
139+
.background(message.isMe ? Color.blue : Color(.systemGray5))
140+
.foregroundStyle(message.isMe ? .white : .primary)
141+
.clipShape(RoundedRectangle(cornerRadius: 16))
142+
.onTapGesture {
143+
withAnimation(.snappy) {
144+
isExpanded.toggle()
145+
}
146+
}
147+
148+
if isExpanded {
149+
HStack(spacing: 12) {
150+
Text(message.timestamp, style: .time)
151+
.font(.caption2)
152+
.foregroundStyle(.secondary)
153+
154+
Button {
155+
withAnimation(.snappy) {
156+
isLiked.toggle()
157+
}
158+
} label: {
159+
Image(systemName: isLiked ? "heart.fill" : "heart")
160+
.font(.caption)
161+
.foregroundStyle(isLiked ? .red : .secondary)
162+
}
163+
}
164+
.transition(.opacity.combined(with: .move(edge: .top)))
165+
}
166+
}
167+
168+
if !message.isMe { Spacer(minLength: 60) }
169+
}
170+
}
171+
}
172+
}
173+
174+
#Preview {
175+
NavigationStack {
176+
LazyVStackDemo()
177+
}
178+
}

Dev/MessagingUIDevelopment/iMessageSwiftDataDemo.swift

Lines changed: 85 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -314,80 +314,24 @@ struct iMessageSwiftDataDemo: View {
314314
scrollsToBottomOnSetItems: true
315315
)
316316
@State private var scrollGeometry: TiledScrollGeometry?
317+
@FocusState private var isInputFocused: Bool
317318

318319
private var isNearBottom: Bool {
319320
guard let geometry = scrollGeometry else { return true }
320321
return geometry.pointsFromBottom < 100
321322
}
322323

323324
var body: some View {
324-
VStack(spacing: 0) {
325+
ZStack {
325326
// Messages
326327
if let store {
327-
ZStack(alignment: .bottomTrailing) {
328-
TiledView(
329-
dataSource: store.dataSource,
330-
scrollPosition: $scrollPosition,
331-
onPrepend: {
332-
store.loadMore()
333-
}
334-
) { message, _ in
335-
iMessageBubbleView(message: message)
336-
.contextMenu {
337-
Button(role: .destructive) {
338-
store.deleteMessage(id: message.id)
339-
} label: {
340-
Label("Delete", systemImage: "trash")
341-
}
342-
}
343-
}
344-
.onScrollGeometryChange { geometry in
345-
scrollGeometry = geometry
346-
}
347-
348-
// Scroll to bottom button
349-
if !isNearBottom {
350-
Button {
351-
scrollPosition.scrollTo(edge: .bottom, animated: true)
352-
} label: {
353-
Image(systemName: "arrow.down.circle.fill")
354-
.font(.title)
355-
.foregroundStyle(.blue)
356-
.background(Circle().fill(.white))
357-
}
358-
.padding()
359-
.transition(.scale.combined(with: .opacity))
360-
}
361-
}
362-
.animation(.easeInOut(duration: 0.2), value: isNearBottom)
328+
loadedContent(store: store)
363329
} else {
364330
Spacer()
365331
ProgressView()
366332
Spacer()
367333
}
368-
369-
Divider()
370-
371-
// Input bar
372-
HStack(spacing: 12) {
373-
TextField("Message", text: $inputText)
374-
.textFieldStyle(.roundedBorder)
375-
.onSubmit {
376-
sendMessage()
377-
}
378-
379-
Button {
380-
sendMessage()
381-
} label: {
382-
Image(systemName: "arrow.up.circle.fill")
383-
.font(.title)
384-
.foregroundStyle(inputText.isEmpty ? .gray : .blue)
385-
}
386-
.disabled(inputText.isEmpty)
387-
}
388-
.padding(.horizontal, 12)
389-
.padding(.vertical, 8)
390-
.background(.bar)
334+
391335
}
392336
.navigationTitle("Messages")
393337
.navigationBarTitleDisplayMode(.inline)
@@ -437,6 +381,87 @@ struct iMessageSwiftDataDemo: View {
437381
}
438382
}
439383
}
384+
385+
@ViewBuilder
386+
private var inputView: some View {
387+
Divider()
388+
389+
// Input bar
390+
HStack(spacing: 12) {
391+
TextField("Message", text: $inputText)
392+
.focused($isInputFocused)
393+
.textFieldStyle(.roundedBorder)
394+
.onSubmit {
395+
sendMessage()
396+
}
397+
398+
Button {
399+
sendMessage()
400+
} label: {
401+
Image(systemName: "arrow.up.circle.fill")
402+
.font(.title)
403+
.foregroundStyle(inputText.isEmpty ? .gray : .blue)
404+
}
405+
.disabled(inputText.isEmpty)
406+
}
407+
.padding(.horizontal, 12)
408+
.padding(.vertical, 8)
409+
.background(.bar)
410+
}
411+
412+
private func loadedContent(store: ChatStore) -> some View {
413+
ZStack(alignment: .bottomTrailing) {
414+
415+
TiledView(
416+
dataSource: store.dataSource,
417+
scrollPosition: $scrollPosition,
418+
onPrepend: {
419+
store.loadMore()
420+
}
421+
) { message, _ in
422+
iMessageBubbleView(message: message)
423+
.contextMenu {
424+
Button(role: .destructive) {
425+
store.deleteMessage(id: message.id)
426+
} label: {
427+
Label("Delete", systemImage: "trash")
428+
}
429+
}
430+
}
431+
.onTapBackground {
432+
isInputFocused = false
433+
}
434+
.onTiledScrollGeometryChange { geometry in
435+
scrollGeometry = geometry
436+
}
437+
438+
// Scroll to bottom button
439+
if !isNearBottom {
440+
Button {
441+
scrollPosition.scrollTo(edge: .bottom, animated: true)
442+
} label: {
443+
Image(systemName: "arrow.down.circle.fill")
444+
.font(.title)
445+
.foregroundStyle(.blue)
446+
.background(Circle().fill(.white))
447+
}
448+
.padding()
449+
.transition(.scale.combined(with: .opacity))
450+
}
451+
452+
}
453+
.safeAreaInset(edge: .bottom, spacing: 0, content: {
454+
inputView
455+
.onGeometryChange(for: CGFloat.self) { proxy in
456+
print(proxy.size)
457+
return proxy.size.height - proxy.frame(in: .global).minY
458+
} action: { newValue in
459+
print("Input view minY: \(newValue)")
460+
}
461+
462+
})
463+
.animation(.easeInOut(duration: 0.2), value: isNearBottom)
464+
}
440465

441466
private func sendMessage() {
442467
guard !inputText.isEmpty else { return }

0 commit comments

Comments
 (0)