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: restore inline alt text list with reorderable photo strip

Revert create gallery photo editor back to the build 36 layout: a
horizontal reorderable thumbnail strip in the Photos section plus a
dedicated Alt Text section that lists each photo at its natural
aspect with an inline alt text field and EXIF line below. Strip
supports long-press drag to reorder with haptic feedback and an
accent-colored X to remove. Add a "Touch and hold to reorder" hint
and drop the now-unused PhotoEditor and LocalZoomableViewer views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+100 -243
+42 -9
Grain/Views/Create/CreateGalleryView.swift
··· 121 121 } label: { 122 122 Label("Take Photo", systemImage: "camera") 123 123 } 124 + 125 + if !photoItems.isEmpty { 126 + ReorderablePhotoStrip(items: $photoItems, selectedPhotoID: $selectedPhotoID) 127 + Label("Touch and hold a photo to reorder", systemImage: "hand.draw") 128 + .font(.caption) 129 + .foregroundStyle(.secondary) 130 + } 124 131 } 125 132 } 126 133 127 134 @ViewBuilder 128 135 private var photoEditorSection: some View { 129 136 if !photoItems.isEmpty { 130 - Section { 131 - PhotoEditor( 132 - items: $photoItems, 133 - selectedPhotoID: $selectedPhotoID, 134 - sendExif: sendExif 135 - ) 137 + Section("Alt Text") { 138 + Text("Alt text describes images for blind and low-vision users, and helps give context to everyone.") 139 + .font(.caption) 140 + .foregroundStyle(.secondary) 141 + ForEach($photoItems) { $item in 142 + VStack(alignment: .leading, spacing: 8) { 143 + HStack(alignment: .top, spacing: 12) { 144 + Image(uiImage: item.thumbnail) 145 + .resizable() 146 + .scaledToFit() 147 + .frame(width: 60) 148 + 149 + TextField("Describe this photo...", text: $item.alt, axis: .vertical) 150 + .font(.subheadline) 151 + .lineLimit(2 ... 4) 152 + } 153 + if let exif = item.exifSummary { 154 + VStack(alignment: .leading, spacing: 2) { 155 + if let camera = exif.camera { 156 + Text(camera).font(.caption) 157 + } 158 + HStack { 159 + Text([exif.shutterSpeed, exif.iso].compactMap(\.self).joined(separator: " ")) 160 + .font(.caption) 161 + Spacer() 162 + Text([exif.focalLength, exif.aperture].compactMap(\.self).joined(separator: " ")) 163 + .font(.caption) 164 + } 165 + } 166 + .foregroundStyle(sendExif ? .secondary : .tertiary) 167 + .padding(.leading, 72) 168 + } 169 + } 170 + .padding(.vertical, 4) 171 + } 136 172 } 137 173 } 138 174 } ··· 752 788 .lineLimit(3 ... 6) 753 789 } header: { 754 790 Text("Gallery") 755 - } 756 - Section { 757 - PhotoEditor(items: $photoItems, selectedPhotoID: $selectedPhotoID, sendExif: true) 758 791 } 759 792 } 760 793 .navigationTitle("New Gallery")
-67
Grain/Views/Create/LocalZoomableViewer.swift
··· 1 - import SwiftUI 2 - 3 - struct LocalZoomableViewer: View { 4 - let image: UIImage 5 - 6 - @State private var scale: CGFloat = 1 7 - @State private var lastScale: CGFloat = 1 8 - @State private var offset: CGSize = .zero 9 - @State private var lastOffset: CGSize = .zero 10 - 11 - var body: some View { 12 - Image(uiImage: image) 13 - .resizable() 14 - .scaledToFit() 15 - .scaleEffect(scale, anchor: .center) 16 - .offset(offset) 17 - .simultaneousGesture(magnification) 18 - .simultaneousGesture( 19 - DragGesture(minimumDistance: scale > 1 ? 0 : .infinity) 20 - .onChanged { value in 21 - offset = CGSize( 22 - width: lastOffset.width + value.translation.width, 23 - height: lastOffset.height + value.translation.height 24 - ) 25 - } 26 - .onEnded { _ in 27 - lastOffset = offset 28 - if scale <= 1.05 { 29 - withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 30 - offset = .zero 31 - lastOffset = .zero 32 - } 33 - } 34 - } 35 - ) 36 - .onChange(of: image) { 37 - withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 38 - scale = 1 39 - lastScale = 1 40 - offset = .zero 41 - lastOffset = .zero 42 - } 43 - } 44 - } 45 - 46 - private var magnification: some Gesture { 47 - MagnificationGesture() 48 - .onChanged { value in 49 - scale = max(1, lastScale * value) 50 - } 51 - .onEnded { value in 52 - let final = max(1, lastScale * value) 53 - if final < 1.05 { 54 - withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 55 - scale = 1 56 - offset = .zero 57 - lastOffset = .zero 58 - } 59 - } 60 - lastScale = scale 61 - } 62 - } 63 - } 64 - 65 - #Preview { 66 - LocalZoomableViewer(image: UIImage(named: "login-bg") ?? UIImage(systemName: "photo")!) 67 - }
-76
Grain/Views/Create/PhotoEditor.swift
··· 1 - import SwiftUI 2 - 3 - struct PhotoEditor: View { 4 - @Binding var items: [PhotoItem] 5 - @Binding var selectedPhotoID: UUID? 6 - let sendExif: Bool 7 - 8 - private var selectedIndex: Int? { 9 - guard let id = selectedPhotoID else { return nil } 10 - return items.firstIndex(where: { $0.id == id }) 11 - } 12 - 13 - var body: some View { 14 - ReorderablePhotoStrip(items: $items, selectedPhotoID: $selectedPhotoID) 15 - .listRowInsets(EdgeInsets()) 16 - .listRowSeparator(.hidden) 17 - if let idx = selectedIndex { 18 - viewer(selectedIndex: idx) 19 - altTextField(for: idx) 20 - exifRow(for: items[idx]) 21 - } 22 - } 23 - 24 - // MARK: - Zoomable Viewer 25 - 26 - private func viewer(selectedIndex _: Int) -> some View { 27 - TabView(selection: $selectedPhotoID) { 28 - ForEach(items) { item in 29 - LocalZoomableViewer(image: item.thumbnail) 30 - .tag(Optional(item.id)) 31 - } 32 - } 33 - .tabViewStyle(.page(indexDisplayMode: .never)) 34 - .frame(height: 280) 35 - } 36 - 37 - // MARK: - EXIF Row 38 - 39 - @ViewBuilder 40 - private func exifRow(for item: PhotoItem) -> some View { 41 - if let exif = item.exifSummary { 42 - VStack(alignment: .leading, spacing: 2) { 43 - if let camera = exif.camera { 44 - Text(camera).font(.caption) 45 - } 46 - HStack { 47 - Text([exif.shutterSpeed, exif.iso].compactMap(\.self).joined(separator: " ")) 48 - .font(.caption) 49 - Spacer() 50 - Text([exif.focalLength, exif.aperture].compactMap(\.self).joined(separator: " ")) 51 - .font(.caption) 52 - } 53 - } 54 - .foregroundStyle(sendExif ? .primary : .tertiary) 55 - } 56 - } 57 - 58 - // MARK: - Alt Text Field 59 - 60 - private func altTextField(for index: Int) -> some View { 61 - TextField("Describe this photo...", text: $items[index].alt, axis: .vertical) 62 - .font(.subheadline) 63 - .lineLimit(2 ... 4) 64 - } 65 - } 66 - 67 - #Preview { 68 - @Previewable @State var state: [PhotoItem] = PreviewData.photoItems 69 - @Previewable @State var selected: UUID? 70 - ScrollView { 71 - PhotoEditor(items: $state, selectedPhotoID: $selected, sendExif: true) 72 - .padding() 73 - } 74 - .onAppear { selected = state.first?.id } 75 - .preferredColorScheme(.dark) 76 - }
+58 -91
Grain/Views/Create/ReorderablePhotoStrip.swift
··· 1 1 import SwiftUI 2 - import UIKit 3 - 4 - // MARK: - UIKit horizontal scroll that works inside Form/List 5 - 6 - private struct UIKitHScroll<Content: View>: UIViewRepresentable { 7 - let content: Content 8 - 9 - func makeUIView(context: Context) -> UIScrollView { 10 - let scroll = UIScrollView() 11 - scroll.showsHorizontalScrollIndicator = false 12 - scroll.alwaysBounceHorizontal = true 13 - scroll.alwaysBounceVertical = false 14 - 15 - let host = context.coordinator.host 16 - host.view.backgroundColor = .clear 17 - scroll.addSubview(host.view) 18 - return scroll 19 - } 20 - 21 - func updateUIView(_ scroll: UIScrollView, context: Context) { 22 - context.coordinator.host.rootView = content 23 - context.coordinator.host.view.sizeToFit() 24 - 25 - let size = context.coordinator.host.view.systemLayoutSizeFitting( 26 - CGSize(width: CGFloat.greatestFiniteMagnitude, height: scroll.bounds.height), 27 - withHorizontalFittingPriority: .fittingSizeLevel, 28 - verticalFittingPriority: .required 29 - ) 30 - context.coordinator.host.view.frame = CGRect(origin: .zero, size: size) 31 - scroll.contentSize = size 32 - } 33 - 34 - func makeCoordinator() -> Coordinator { 35 - Coordinator(content: content) 36 - } 37 - 38 - @MainActor class Coordinator { 39 - let host: UIHostingController<Content> 40 - init(content: Content) { 41 - host = UIHostingController(rootView: content) 42 - host.view.backgroundColor = .clear 43 - } 44 - } 45 - } 46 - 47 - // MARK: - Strip View 48 2 49 3 struct ReorderablePhotoStrip: View { 50 4 @Binding var items: [PhotoItem] ··· 55 9 private let spacing: CGFloat = 8 56 10 57 11 var body: some View { 58 - UIKitHScroll(content: stripContent) 59 - .frame(height: thumbSize + 16) 60 - } 12 + ScrollView(.horizontal, showsIndicators: false) { 13 + HStack(spacing: spacing) { 14 + ForEach(items) { item in 15 + ZStack(alignment: .topTrailing) { 16 + Image(uiImage: item.thumbnail) 17 + .resizable() 18 + .scaledToFill() 19 + .frame(width: thumbSize, height: thumbSize) 20 + .clipShape(RoundedRectangle(cornerRadius: 8)) 61 21 62 - private var stripContent: some View { 63 - HStack(spacing: spacing) { 64 - ForEach(items) { item in 65 - thumbnailCell(item: item) 66 - } 67 - } 68 - .padding(.top, 12) 69 - .padding(.bottom, 4) 70 - .padding(.horizontal, 16) 71 - } 72 - 73 - private func thumbnailCell(item: PhotoItem) -> some View { 74 - ZStack(alignment: .topTrailing) { 75 - Image(uiImage: item.thumbnail) 76 - .resizable() 77 - .scaledToFill() 78 - .frame(width: thumbSize, height: thumbSize) 79 - .reorderableThumbnail( 80 - isDragging: draggingID == item.id, 81 - isSelected: item.id == selectedPhotoID 82 - ) 83 - 84 - Button { 85 - withAnimation { 86 - items.removeAll { $0.id == item.id } 87 - if selectedPhotoID == item.id { 88 - selectedPhotoID = items.first?.id 22 + Button { 23 + withAnimation { 24 + items.removeAll { $0.id == item.id } 25 + if selectedPhotoID == item.id { 26 + selectedPhotoID = items.first?.id 27 + } 28 + } 29 + } label: { 30 + Image(systemName: "xmark.circle.fill") 31 + .font(.system(size: 18)) 32 + .foregroundStyle(.white, Color("AccentColor")) 33 + } 34 + .buttonStyle(.plain) 35 + .offset(x: 4, y: -4) 89 36 } 37 + .offset(x: draggingID == item.id ? dragOffset : 0) 38 + .opacity(draggingID == item.id ? 0.8 : 1) 39 + .scaleEffect(draggingID == item.id ? 1.08 : 1) 40 + .zIndex(draggingID == item.id ? 1 : 0) 41 + .gesture( 42 + LongPressGesture(minimumDuration: 0.25) 43 + .sequenced(before: DragGesture(minimumDistance: 0)) 44 + .onChanged { value in 45 + switch value { 46 + case .second(true, let drag): 47 + if draggingID == nil { 48 + draggingID = item.id 49 + UIImpactFeedbackGenerator(style: .medium).impactOccurred() 50 + } 51 + if let drag { 52 + dragOffset = drag.translation.width 53 + reorderIfNeeded() 54 + } 55 + default: 56 + break 57 + } 58 + } 59 + .onEnded { _ in 60 + withAnimation(.easeInOut(duration: 0.2)) { 61 + draggingID = nil 62 + dragOffset = 0 63 + } 64 + } 65 + ) 90 66 } 91 - } label: { 92 - Image(systemName: "xmark.circle.fill") 93 - .font(.system(size: 18)) 94 - .foregroundStyle(.white, Color("AccentColor")) 95 67 } 96 - .offset(x: 4, y: -4) 97 - } 98 - .zIndex(draggingID == item.id ? 1 : 0) 99 - .id(item.id) 100 - .offset(x: draggingID == item.id ? dragOffset : 0) 101 - .onTapGesture { 102 - guard draggingID == nil else { return } 103 - selectedPhotoID = item.id 68 + .padding(.vertical, 4) 104 69 } 105 70 } 106 71 ··· 111 76 112 77 let step = thumbSize + spacing 113 78 let steps = Int((dragOffset / step).rounded()) 114 - 115 79 let targetIndex = max(0, min(items.count - 1, currentIndex + steps)) 116 80 if targetIndex != currentIndex { 117 - withAnimation(.spring(response: 0.45, dampingFraction: 0.7, blendDuration: 0)) { 118 - items.move(fromOffsets: IndexSet(integer: currentIndex), toOffset: targetIndex > currentIndex ? targetIndex + 1 : targetIndex) 81 + withAnimation(.easeInOut(duration: 0.15)) { 82 + items.move( 83 + fromOffsets: IndexSet(integer: currentIndex), 84 + toOffset: targetIndex > currentIndex ? targetIndex + 1 : targetIndex 85 + ) 119 86 } 120 87 dragOffset -= CGFloat(targetIndex - currentIndex) * step 121 88 }