iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: keyboard-navigable login handle suggestions

Arrow keys and Tab now move the selection through the typeahead
list while the handle TextField stays focused. Up-arrow off the
first row deselects and returns the cursor to the TextField; Tab
wraps from the last row back to the first. Return submits the
highlighted row (or the raw text if nothing is selected) with the
same code path as a click.

The highlight itself is a single matched-geometry liquid-glass
layer tinted with the accent color, so moving between rows is a
physical slide rather than a color snap. The layer is inset from
the container edge and rounded, fixing the hard rectangular edges
that were bleeding into the surrounding glass card.

Also adds a ScrollViewReader `scrollTo` on highlight change so a
highlighted row that's outside the viewport (e.g. when the
keyboard has pushed the list) smoothly centers itself.

authored by

Hima Aramona and committed by
Chad Miller
67f36877 e3e6ba61

+79 -6
+79 -6
Grain/Views/LoginView.swift
··· 14 14 @State private var errorMessage: String? 15 15 @State private var suggestions: [ActorSuggestion] = [] 16 16 @State private var searchTask: Task<Void, Never>? 17 + @State private var highlightedSuggestionIndex: Int? 18 + @Namespace private var suggestionHighlightNS 17 19 18 20 var body: some View { 19 21 GeometryReader { geo in ··· 104 106 .autocorrectionDisabled() 105 107 .textInputAutocapitalization(.never) 106 108 .submitLabel(.go) 107 - .onSubmit { 108 - if !handle.isEmpty { Task { await login() } } 109 - } 109 + .onSubmit(submitFromKeyboard) 110 110 .onChange(of: handle) { 111 111 searchTask?.cancel() 112 112 let query = handle ··· 118 118 await searchActors(query: query) 119 119 } 120 120 } 121 + .onKeyPress(.downArrow) { 122 + moveSuggestionHighlight(by: 1) 123 + } 124 + .onKeyPress(.upArrow) { 125 + moveSuggestionHighlight(by: -1) 126 + } 127 + .onKeyPress(.tab) { 128 + moveSuggestionHighlight(by: 1, wrap: true) 129 + } 121 130 122 131 if isSearching { 123 132 ProgressView() ··· 136 145 // Suggestions 137 146 if !suggestions.isEmpty { 138 147 VStack(spacing: 0) { 139 - ForEach(suggestions) { actor in 148 + ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, actor in 149 + let isHighlighted = highlightedSuggestionIndex == index 140 150 Button { 141 151 handle = actor.handle 142 152 suggestions = [] 153 + highlightedSuggestionIndex = nil 143 154 Task { await login() } 144 155 } label: { 145 156 HStack(spacing: 10) { ··· 176 187 } 177 188 .padding(.horizontal, 16) 178 189 .padding(.vertical, 8) 190 + .background { 191 + if isHighlighted { 192 + Color.clear 193 + .glassEffect( 194 + .regular.tint(Color.accentColor.opacity(0.45)), 195 + in: .rect(cornerRadius: 12) 196 + ) 197 + .padding(.horizontal, 6) 198 + .padding(.vertical, 2) 199 + .matchedGeometryEffect(id: "suggestion-highlight", in: suggestionHighlightNS) 200 + } 201 + } 179 202 .contentShape(Rectangle()) 180 203 } 181 204 .buttonStyle(.plain) 205 + .id(actor.id) 182 206 183 207 if actor.id != suggestions.last?.id { 184 208 Divider() ··· 254 278 .frame(height: 60) 255 279 } 256 280 .frame(minHeight: geo.size.height) 257 - .onChange(of: suggestions) { 258 - if !suggestions.isEmpty { 281 + .onChange(of: suggestions) { _, newValue in 282 + if newValue.isEmpty { 283 + highlightedSuggestionIndex = nil 284 + } else if let idx = highlightedSuggestionIndex, idx >= newValue.count { 285 + highlightedSuggestionIndex = newValue.count - 1 286 + } 287 + if !newValue.isEmpty { 259 288 withAnimation { 260 289 proxy.scrollTo("suggestions", anchor: .bottom) 261 290 } 262 291 } 263 292 } 293 + .onChange(of: highlightedSuggestionIndex) { _, newIndex in 294 + guard let idx = newIndex, suggestions.indices.contains(idx) else { return } 295 + withAnimation(.easeInOut(duration: 0.2)) { 296 + proxy.scrollTo(suggestions[idx].id, anchor: .center) 297 + } 298 + } 264 299 } 265 300 .scrollDismissesKeyboard(.interactively) 266 301 .scrollIndicators(.hidden) 267 302 } // ScrollViewReader 303 + } 304 + } 305 + 306 + /// Move the keyboard highlight up or down the suggestions list. 307 + /// `wrap` (used for Tab) jumps back to the first item after the last. 308 + /// Up-arrow off the first item deselects so the TextField regains focus. 309 + /// The state change is wrapped in `withAnimation` so the matched-geometry 310 + /// highlight layer physically slides between rows. 311 + private func moveSuggestionHighlight(by delta: Int, wrap: Bool = false) -> KeyPress.Result { 312 + guard !suggestions.isEmpty else { return .ignored } 313 + let last = suggestions.count - 1 314 + withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) { 315 + if let current = highlightedSuggestionIndex { 316 + let next = current + delta 317 + if next < 0 { 318 + highlightedSuggestionIndex = nil 319 + } else if next > last { 320 + highlightedSuggestionIndex = wrap ? 0 : last 321 + } else { 322 + highlightedSuggestionIndex = next 323 + } 324 + } else { 325 + highlightedSuggestionIndex = delta >= 0 ? 0 : last 326 + } 327 + } 328 + return .handled 329 + } 330 + 331 + /// Submit either the highlighted suggestion or the raw handle text, 332 + /// mirroring the behavior of clicking a row vs. tapping Sign In. 333 + private func submitFromKeyboard() { 334 + if let idx = highlightedSuggestionIndex, suggestions.indices.contains(idx) { 335 + handle = suggestions[idx].handle 336 + suggestions = [] 337 + highlightedSuggestionIndex = nil 338 + Task { await login() } 339 + } else if !handle.isEmpty { 340 + Task { await login() } 268 341 } 269 342 } 270 343