|
1 | 1 | # swiftui-messaging-ui |
2 | 2 |
|
3 | | -A SwiftUI component for building messaging interfaces with support for loading older messages and automatic scroll management. |
| 3 | +A high-performance SwiftUI list component built on UICollectionView, designed for messaging interfaces and infinite scrolling lists. |
4 | 4 |
|
5 | 5 | ## Features |
6 | 6 |
|
7 | | -- **Message List**: Generic, scrollable message list component |
8 | | -- **Automatic Scroll Management**: Handles scroll position when loading older messages |
9 | | -- **Auto Scroll to Bottom**: Optional automatic scrolling to bottom for new messages |
10 | | -- **Loading State Management**: Internal loading state management with async/await support |
| 7 | +- **TiledView**: High-performance list view using UICollectionView with custom layout |
| 8 | +- **ListDataSource**: Change-tracking data source with efficient diff detection |
| 9 | +- **CellState**: Type-safe per-cell state management |
| 10 | +- **Scroll Position Control**: Programmatic scrolling with SwiftUI-like API |
| 11 | +- **Prepend Support**: Maintains scroll position when loading older content |
| 12 | +- **Self-Sizing Cells**: Automatic cell height calculation |
11 | 13 |
|
12 | 14 | ## Requirements |
13 | 15 |
|
@@ -41,100 +43,212 @@ Or add it through Xcode: |
41 | 43 | import MessagingUI |
42 | 44 | import SwiftUI |
43 | 45 |
|
44 | | -struct Message: Identifiable { |
45 | | - let id: UUID |
46 | | - let text: String |
| 46 | +struct Message: Identifiable, Equatable { |
| 47 | + let id: Int |
| 48 | + var text: String |
47 | 49 | } |
48 | 50 |
|
49 | 51 | struct ChatView: View { |
50 | | - @State private var messages: [Message] = [] |
| 52 | + @State private var dataSource = ListDataSource<Message>() |
| 53 | + @State private var scrollPosition = TiledScrollPosition() |
51 | 54 |
|
52 | 55 | var body: some View { |
53 | | - MessageList( |
54 | | - messages: messages, |
55 | | - onLoadOlderMessages: { |
56 | | - // Load older messages asynchronously |
57 | | - let olderMessages = await loadOlderMessages() |
58 | | - messages.insert(contentsOf: olderMessages, at: 0) |
| 56 | + TiledView( |
| 57 | + dataSource: dataSource, |
| 58 | + scrollPosition: $scrollPosition, |
| 59 | + cellBuilder: { message, _ in |
| 60 | + MessageBubble(message: message) |
59 | 61 | } |
60 | | - ) { message in |
61 | | - Text(message.text) |
62 | | - .padding() |
63 | | - .background(Color.blue.opacity(0.1)) |
64 | | - .cornerRadius(8) |
| 62 | + ) |
| 63 | + .onAppear { |
| 64 | + dataSource.setItems(initialMessages) |
65 | 65 | } |
66 | 66 | } |
| 67 | +} |
| 68 | +``` |
67 | 69 |
|
68 | | - func loadOlderMessages() async -> [Message] { |
69 | | - // Your loading logic here |
70 | | - [] |
| 70 | +### Loading Older Messages (Prepend) |
| 71 | + |
| 72 | +```swift |
| 73 | +TiledView( |
| 74 | + dataSource: dataSource, |
| 75 | + scrollPosition: $scrollPosition, |
| 76 | + onPrepend: { |
| 77 | + // Called when user scrolls near the top |
| 78 | + let olderMessages = await loadOlderMessages() |
| 79 | + dataSource.prepend(olderMessages) |
| 80 | + }, |
| 81 | + cellBuilder: { message, _ in |
| 82 | + MessageBubble(message: message) |
71 | 83 | } |
| 84 | +) |
| 85 | +``` |
| 86 | + |
| 87 | +### Programmatic Scrolling |
| 88 | + |
| 89 | +```swift |
| 90 | +@State private var scrollPosition = TiledScrollPosition() |
| 91 | + |
| 92 | +// Scroll to bottom |
| 93 | +Button("Scroll to Bottom") { |
| 94 | + scrollPosition.scrollTo(edge: .bottom) |
| 95 | +} |
| 96 | + |
| 97 | +// Scroll to top |
| 98 | +Button("Scroll to Top") { |
| 99 | + scrollPosition.scrollTo(edge: .top, animated: false) |
72 | 100 | } |
73 | 101 | ``` |
74 | 102 |
|
75 | | -### With Auto Scroll to Bottom |
| 103 | +### Using CellState |
| 104 | + |
| 105 | +CellState allows you to manage per-cell UI state (like expansion, selection) separately from your data model. |
76 | 106 |
|
77 | 107 | ```swift |
78 | | -MessageList( |
79 | | - messages: messages, |
80 | | - autoScrollToBottom: $autoScrollToBottom, |
81 | | - onLoadOlderMessages: { |
82 | | - await loadOlderMessages() |
| 108 | +// 1. Define a state key |
| 109 | +enum IsExpandedKey: CustomStateKey { |
| 110 | + typealias Value = Bool |
| 111 | + static var defaultValue: Bool { false } |
| 112 | +} |
| 113 | + |
| 114 | +// 2. Add convenience accessor |
| 115 | +extension CellState { |
| 116 | + var isExpanded: Bool { |
| 117 | + get { self[IsExpandedKey.self] } |
| 118 | + set { self[IsExpandedKey.self] = newValue } |
83 | 119 | } |
84 | | -) { message in |
85 | | - MessageView(message: message) |
86 | 120 | } |
| 121 | + |
| 122 | +// 3. Use in cell builder |
| 123 | +TiledView( |
| 124 | + dataSource: dataSource, |
| 125 | + scrollPosition: $scrollPosition, |
| 126 | + cellBuilder: { message, state in |
| 127 | + MessageBubble( |
| 128 | + message: message, |
| 129 | + isExpanded: state.isExpanded |
| 130 | + ) |
| 131 | + } |
| 132 | +) |
87 | 133 | ``` |
88 | 134 |
|
89 | | -## API |
| 135 | +### ListDataSource Operations |
| 136 | + |
| 137 | +```swift |
| 138 | +var dataSource = ListDataSource<Message>() |
90 | 139 |
|
91 | | -### MessageList |
| 140 | +// Initial load |
| 141 | +dataSource.setItems(messages) |
92 | 142 |
|
93 | | -A generic message list component that displays messages using a custom view builder. |
| 143 | +// Add to beginning (older messages) |
| 144 | +dataSource.prepend(olderMessages) |
| 145 | + |
| 146 | +// Add to end (new messages) |
| 147 | +dataSource.append(newMessages) |
| 148 | + |
| 149 | +// Insert at specific position |
| 150 | +dataSource.insert(messages, at: 5) |
| 151 | + |
| 152 | +// Update existing items |
| 153 | +dataSource.update([updatedMessage]) |
| 154 | + |
| 155 | +// Remove items |
| 156 | +dataSource.remove(id: messageId) |
| 157 | +dataSource.remove(ids: [id1, id2, id3]) |
| 158 | + |
| 159 | +// Auto-detect changes from new array |
| 160 | +dataSource.applyDiff(from: newMessagesArray) |
| 161 | +``` |
94 | 162 |
|
95 | | -#### Initializers |
| 163 | +## API Reference |
| 164 | + |
| 165 | +### TiledView |
| 166 | + |
| 167 | +A SwiftUI view that wraps UICollectionView for high-performance list rendering. |
96 | 168 |
|
97 | 169 | ```swift |
98 | | -// Simple message list without older message loading |
99 | | -init( |
100 | | - messages: [Message], |
101 | | - @ViewBuilder content: @escaping (Message) -> Content |
102 | | -) |
| 170 | +public struct TiledView<Item: Identifiable & Equatable, Cell: View>: UIViewRepresentable { |
| 171 | + public init( |
| 172 | + dataSource: ListDataSource<Item>, |
| 173 | + scrollPosition: Binding<TiledScrollPosition>, |
| 174 | + cellStates: [Item.ID: CellState]? = nil, |
| 175 | + onPrepend: (@MainActor () async throws -> Void)? = nil, |
| 176 | + @ViewBuilder cellBuilder: @escaping (Item, CellState) -> Cell |
| 177 | + ) |
| 178 | +} |
| 179 | +``` |
103 | 180 |
|
104 | | -// Message list with older message loading support |
105 | | -init( |
106 | | - messages: [Message], |
107 | | - autoScrollToBottom: Binding<Bool>? = nil, |
108 | | - onLoadOlderMessages: @escaping @MainActor () async -> Void, |
109 | | - @ViewBuilder content: @escaping (Message) -> Content |
110 | | -) |
| 181 | +### ListDataSource |
| 182 | + |
| 183 | +A change-tracking data source that enables efficient list updates. |
| 184 | + |
| 185 | +```swift |
| 186 | +public struct ListDataSource<Item: Identifiable & Equatable> { |
| 187 | + public var items: Deque<Item> { get } |
| 188 | + public var changeCounter: Int { get } |
| 189 | + |
| 190 | + public mutating func setItems(_ items: [Item]) |
| 191 | + public mutating func prepend(_ items: [Item]) |
| 192 | + public mutating func append(_ items: [Item]) |
| 193 | + public mutating func insert(_ items: [Item], at index: Int) |
| 194 | + public mutating func update(_ items: [Item]) |
| 195 | + public mutating func remove(id: Item.ID) |
| 196 | + public mutating func remove(ids: [Item.ID]) |
| 197 | + public mutating func applyDiff(from newItems: [Item]) |
| 198 | +} |
111 | 199 | ``` |
112 | 200 |
|
113 | | -#### Parameters |
| 201 | +### TiledScrollPosition |
114 | 202 |
|
115 | | -- `messages`: Array of messages to display. Must conform to `Identifiable`. |
116 | | -- `autoScrollToBottom`: Optional binding that controls automatic scrolling to bottom when new messages are added. |
117 | | -- `onLoadOlderMessages`: Async closure called when user scrolls up to trigger loading older messages. |
118 | | -- `content`: A view builder that creates the view for each message. |
| 203 | +A struct for programmatic scroll control, similar to SwiftUI's `ScrollPosition`. |
119 | 204 |
|
120 | | -## How It Works |
| 205 | +```swift |
| 206 | +public struct TiledScrollPosition { |
| 207 | + public enum Edge { |
| 208 | + case top |
| 209 | + case bottom |
| 210 | + } |
121 | 211 |
|
122 | | -### Scroll Position Management |
| 212 | + public mutating func scrollTo(edge: Edge, animated: Bool = true) |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +### CellState |
| 217 | + |
| 218 | +Type-safe per-cell state storage. |
| 219 | + |
| 220 | +```swift |
| 221 | +public protocol CustomStateKey { |
| 222 | + associatedtype Value |
| 223 | + static var defaultValue: Value { get } |
| 224 | +} |
| 225 | + |
| 226 | +public struct CellState { |
| 227 | + public static var empty: CellState { get } |
| 228 | + public subscript<T: CustomStateKey>(key: T.Type) -> T.Value { get set } |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +## How It Works |
123 | 233 |
|
124 | | -The component intelligently manages scroll position based on the current state: |
| 234 | +### Virtual Content Layout |
125 | 235 |
|
126 | | -1. **Loading Older Messages** (highest priority): When loading older messages, the scroll position is preserved by adjusting the content offset. |
127 | | -2. **Auto Scroll to Bottom**: When enabled, automatically scrolls to the bottom when new messages are added. |
128 | | -3. **Normal Operation**: No scroll adjustment, maintaining the user's current scroll position. |
| 236 | +TiledView uses a custom UICollectionViewLayout with virtual content height. This enables: |
| 237 | +- Efficient prepend operations without scroll jumps |
| 238 | +- Smooth bidirectional scrolling |
| 239 | +- Minimal memory footprint |
129 | 240 |
|
130 | | -### Loading State |
| 241 | +### Change Tracking |
131 | 242 |
|
132 | | -The loading state is managed internally using async/await. When the user scrolls up to the trigger point: |
| 243 | +ListDataSource tracks every mutation as a `Change` enum: |
| 244 | +- `.setItems` - Complete replacement |
| 245 | +- `.prepend([ID])` - Items added to beginning |
| 246 | +- `.append([ID])` - Items added to end |
| 247 | +- `.insert(at:, ids:)` - Items inserted at index |
| 248 | +- `.update([ID])` - Existing items modified |
| 249 | +- `.remove([ID])` - Items removed |
133 | 250 |
|
134 | | -1. The component sets the internal loading flag |
135 | | -2. Calls your `onLoadOlderMessages` closure |
136 | | -3. Automatically adjusts scroll position to maintain the user's viewing context |
137 | | -4. Clears the loading flag after completion |
| 251 | +TiledView applies only new changes since its last update, ensuring optimal performance. |
138 | 252 |
|
139 | 253 | ## License |
140 | 254 |
|
|
0 commit comments