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.

refactor: shared ExifDisplayData model for feed card (#8)

* refactor: replace GrainExif dependency with ExifDisplayData in ExifInfoView

Introduces ExifDisplayData, a display-only struct that both GrainExif (feed)
and ExifSummary (create flow) map to via .displayData extensions. ExifInfoView
now accepts this shared type plus a style param for per-call dimming.

ExifSettingsRow: removes dot separators, adds smooth position-shift animation
(.animation(.smooth, value: tokens)) for digit-count changes, suppresses text
content crossfades via .contentTransition(.identity), and snaps
appearance/disappearance via zero-duration transition. VStack-level opacity
with .easeInOut(0.2) fades all rows together when the whole exif block
appears or disappears.

* feat: wire ExifDisplayData into GalleryCardView feed card

Passes currentExif?.displayData to ExifInfoView instead of the raw GrainExif,
so the feed card uses the same shared component as the create flow.

* fix: block inherited animations from reaching ExifInfoView in feed

Add .transaction { \$0.animation = nil } to the ExifInfoView call in
GalleryCardView so copiedToast's spring and the bare withAnimation in the
media-warning reveal can't bleed into the EXIF rows.

Remove the Form-level .animation(.smooth, value: selectedPhotoID) from
CreateGalleryView — it was the primary source of inherited smooth transactions
reaching ExifInfoView on every photo tap.

* fix: ForEach indices ambiguity and add StringExtensions

* fix: center X button on thumbnail corner and even vertical padding in strip

authored by

Hima and committed by
GitHub
f19e9a93 4516eee1

+128 -42
+8
Grain/Utilities/StringExtensions.swift
··· 1 + extension String { 2 + /// Returns the string with every digit replaced by "8", reserving the 3 + /// maximum possible width for any value sharing the same character structure. 4 + /// Use as a hidden layout proxy alongside the real text. 5 + var digitWidthProxy: String { 6 + reduce(into: "") { $0.append(("0" ... "9").contains($1) ? "8" : $1) } 7 + } 8 + }
+104 -32
Grain/Views/Components/ExifInfoView.swift
··· 1 1 import SwiftUI 2 2 3 + /// Lightweight display model shared by `ExifInfoView`. 4 + /// Both `GrainExif` (feed) and `ExifSummary` (create flow) map to this 5 + /// via their `displayData` computed properties defined below. 6 + struct ExifDisplayData { 7 + var camera: String? 8 + var lens: String? 9 + var focalLength: String? 10 + var fNumber: String? 11 + var exposureTime: String? 12 + var iso: String? 13 + } 14 + 15 + extension GrainExif { 16 + var displayData: ExifDisplayData { 17 + ExifDisplayData( 18 + camera: cameraName, 19 + lens: lensName, 20 + focalLength: formattedFocalLength, 21 + fNumber: formattedFNumber, 22 + exposureTime: formattedExposureTime, 23 + iso: iSO.map { "ISO \($0)" } 24 + ) 25 + } 26 + } 27 + 28 + extension ExifSummary { 29 + var displayData: ExifDisplayData { 30 + ExifDisplayData( 31 + camera: camera, 32 + lens: lens, 33 + focalLength: focalLength, 34 + fNumber: aperture, 35 + exposureTime: shutterSpeed, 36 + iso: iso 37 + ) 38 + } 39 + } 40 + 3 41 struct ExifInfoView: View { 4 - let exif: GrainExif 42 + let exif: ExifDisplayData? 43 + /// Always reserve layout space for the camera row even when the current exif is nil. 44 + /// Set true when any photo in the gallery has a camera name. 45 + var reserveCameraRow: Bool = false 46 + /// Always reserve layout space for the lens row even when the current exif is nil. 47 + var reserveLensRow: Bool = false 48 + /// Foreground style for all text and icons. 49 + var style: AnyShapeStyle = .init(.secondary) 5 50 6 51 var body: some View { 7 - VStack(alignment: .leading, spacing: 4) { 8 - if let camera = exif.cameraName { 9 - HStack(spacing: 6) { 10 - Image(systemName: "camera") 11 - .font(.caption2) 12 - Text(camera) 13 - .font(.caption) 52 + let showCamera = reserveCameraRow || exif?.camera != nil 53 + let showLens = reserveLensRow || exif?.lens != nil 54 + if showCamera || showLens || exif != nil { 55 + VStack(alignment: .leading, spacing: 4) { 56 + if showCamera { 57 + HStack(spacing: 6) { 58 + Image(systemName: "camera").font(.caption2) 59 + Text(exif?.camera ?? " ").font(.caption) 60 + } 61 + .foregroundStyle(style) 62 + .opacity(exif?.camera != nil ? 1 : 0) 14 63 } 15 - .foregroundStyle(.secondary) 16 - } 17 - if let lens = exif.lensName { 18 - HStack(spacing: 6) { 19 - Image(systemName: "circle.circle") 20 - .font(.caption2) 21 - Text(lens) 22 - .font(.caption) 64 + if showLens { 65 + HStack(spacing: 6) { 66 + Image(systemName: "circle.circle").font(.caption2) 67 + Text(exif?.lens ?? " ").font(.caption) 68 + } 69 + .foregroundStyle(style) 70 + .opacity(exif?.lens != nil ? 1 : 0) 23 71 } 24 - .foregroundStyle(.secondary) 72 + ExifSettingsRow( 73 + tokens: [exif?.focalLength, exif?.fNumber, exif?.exposureTime, exif?.iso], 74 + style: style 75 + ) 25 76 } 26 - if let settings = exif.settingsLine { 27 - Text(settings) 28 - .font(.caption) 29 - .foregroundStyle(.secondary) 77 + // Single opacity for the whole block so all rows appear/disappear together. 78 + // nil animation suppresses any inherited transaction so it's always instant. 79 + .opacity(exif != nil ? 1 : 0) 80 + .animation(.easeInOut(duration: 0.2), value: exif == nil) 81 + } 82 + } 83 + } 84 + 85 + struct ExifSettingsRow: View { 86 + let tokens: [String?] 87 + var style: AnyShapeStyle = .init(.secondary) 88 + 89 + var body: some View { 90 + ZStack(alignment: .leading) { 91 + Text(" ").hidden() // holds caption line height when all tokens are nil 92 + HStack(spacing: 6) { 93 + ForEach(Array(tokens.enumerated()), id: \.offset) { _, value in 94 + if let value { 95 + Text(value.digitWidthProxy) 96 + .hidden() 97 + .overlay(Text(value).contentTransition(.identity)) 98 + // Zero-duration overrides the parent .smooth context for this 99 + // view's appearance/disappearance — snaps in/out instantly while 100 + // sibling positions still shift smoothly via the HStack animation. 101 + .transition(.opacity.animation(.linear(duration: 0))) 102 + } 103 + } 30 104 } 105 + .animation(.smooth, value: tokens) 31 106 } 107 + .font(.caption) 108 + .foregroundStyle(style) 32 109 } 33 110 } 34 111 35 112 #Preview { 36 - ExifInfoView(exif: GrainExif( 37 - uri: "at://preview", 38 - cid: "cid", 39 - photo: "at://preview/photo", 40 - createdAt: "2024-06-15T18:00:00Z", 113 + ExifInfoView(exif: ExifDisplayData( 114 + camera: "Leica M11", 115 + lens: "Summilux-M 35mm f/1.4", 116 + focalLength: "35mm", 117 + fNumber: "f/2", 41 118 exposureTime: "1/500", 42 - fNumber: "f/2.0", 43 - focalLengthIn35mmFormat: "35mm", 44 - iSO: 200, 45 - lensModel: "Summilux-M 35mm f/1.4", 46 - make: "Leica", 47 - model: "M11" 119 + iso: "ISO 200" 48 120 )) 49 121 .padding() 50 122 }
+14 -8
Grain/Views/Components/GalleryCardView.swift
··· 474 474 475 475 @ViewBuilder 476 476 private func captionSection(lr: LabelResolution) -> some View { 477 - // EXIF info 478 - if let photos = gallery.items, !photos.isEmpty, 479 - let exif = photos[currentPage].exif, 480 - exif.hasDisplayableData 481 - { 482 - ExifInfoView(exif: exif) 483 - .padding(.horizontal, 12) 484 - .padding(.top, 8) 477 + // EXIF info — always rendered when any photo in the gallery has exif so 478 + // the card height stays locked; content fades in/out per photo. 479 + let allPhotos = gallery.items ?? [] 480 + if allPhotos.contains(where: { $0.exif?.hasDisplayableData ?? false }) { 481 + let currentExif = allPhotos.indices.contains(currentPage) 482 + ? allPhotos[currentPage].exif : nil 483 + ExifInfoView( 484 + exif: currentExif?.displayData, 485 + reserveCameraRow: allPhotos.contains(where: { $0.exif?.cameraName != nil }), 486 + reserveLensRow: allPhotos.contains(where: { $0.exif?.lensName != nil }) 487 + ) 488 + .transaction { $0.animation = nil } 489 + .padding(.horizontal, 12) 490 + .padding(.top, 8) 485 491 } 486 492 487 493 // Title & description
+2 -2
Grain/Views/Create/ReorderablePhotoStrip.swift
··· 32 32 .foregroundStyle(.white, Color("AccentColor")) 33 33 } 34 34 .buttonStyle(.plain) 35 - .offset(x: 4, y: -4) 35 + .offset(x: 9, y: -9) 36 36 } 37 37 .offset(x: draggingID == item.id ? dragOffset : 0) 38 38 .opacity(draggingID == item.id ? 0.8 : 1) ··· 65 65 ) 66 66 } 67 67 } 68 - .padding(.vertical, 4) 68 + .padding(.vertical, 10) 69 69 } 70 70 } 71 71