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: like button particle burst, symbol transition, and double-tap fixes

- Add LikeParticleView: subtle purple hearts that float from the heart
button on like (engagement row tap and photo double-tap)
- Support N concurrent bursts via [UUID] array so rapid taps stack
- Double-tap always bursts particles even if already liked
- Add .contentTransition(.symbolEffect .replace.downUp.byLayer) on the
heart icon for a smooth heart → heart.fill swap
- Animate HStack layout on favCount change so sibling buttons slide smoothly
- Fix double-tap heart position on landscape photos with black bars:
PinchZoomOverlay is now a ZStack sibling of LazyImage so the UIView
covers the full carousel slot — coordinates map correctly with no math,
and black-bar areas are tappable
- Consolidate shareWiggle + didLongPressShare → shareAnimating
- Extract addParticleBurst() and triggerFavoriteToggle() to remove duplication

+107 -46
+79 -22
Grain/Views/Components/GalleryCardView.swift
··· 92 92 } 93 93 } 94 94 95 + // MARK: - Subtle like-button particle burst 96 + 97 + private struct LikeParticleView: View { 98 + let index: Int 99 + 100 + /// Deterministic per slot — no random state stored in parent 101 + private static let configs: [(x: CGFloat, y: CGFloat, scale: CGFloat)] = [ 102 + (x: -15, y: -30, scale: 0.85), 103 + (x: -4, y: -38, scale: 1.00), 104 + (x: 7, y: -32, scale: 0.90), 105 + (x: 17, y: -26, scale: 0.80), 106 + (x: 2, y: -43, scale: 0.95), 107 + ] 108 + 109 + @State private var scale: CGFloat = 0.3 110 + @State private var offset: CGSize = .zero 111 + @State private var opacity: Double = 0.9 112 + 113 + var body: some View { 114 + let cfg = Self.configs[index] 115 + Image(systemName: "heart.fill") 116 + .font(.system(size: 10)) 117 + .foregroundStyle(Color("AccentColor").opacity(0.9)) 118 + .scaleEffect(scale) 119 + .offset(offset) 120 + .opacity(opacity) 121 + .onAppear { 122 + let delay = Double(index) * 0.07 123 + withAnimation(.easeOut(duration: 0.55).delay(delay)) { 124 + scale = cfg.scale 125 + offset = CGSize(width: cfg.x, height: cfg.y) 126 + } 127 + withAnimation(.easeIn(duration: 0.38).delay(delay + 0.32)) { 128 + opacity = 0 129 + } 130 + } 131 + } 132 + } 133 + 95 134 struct GalleryCardView: View { 96 135 @Environment(AuthManager.self) private var auth 97 136 @Environment(StoryStatusCache.self) private var storyStatusCache ··· 105 144 var onLocationTap: ((String, String) -> Void)? 106 145 var onStoryTap: ((GrainStoryAuthor) -> Void)? 107 146 @State private var isFavoriting = false 147 + @State private var likeParticleBursts: [UUID] = [] 108 148 @State private var currentPage = 0 109 149 @State private var showingAlt = false 110 150 @State private var hearts: [HeartAnimationState] = [] 111 151 @State private var showCopiedToast = false 112 - @State private var shareWiggle = false 113 - @State private var didLongPressShare = false 152 + @State private var shareAnimating = false 114 153 @State private var prefetcher = ImagePrefetcher() 115 154 116 155 private var isFavorited: Bool { ··· 346 385 HStack(spacing: 16) { 347 386 Button { 348 387 guard !isFavoriting else { return } 349 - isFavoriting = true 350 - Task { 351 - await toggleFavorite() 352 - isFavoriting = false 353 - } 388 + if !isFavorited { addParticleBurst() } 389 + triggerFavoriteToggle() 354 390 } label: { 355 391 HStack(spacing: 5) { 356 392 Image(systemName: isFavorited ? "heart.fill" : "heart") 357 393 .font(.system(size: 22)) 394 + .contentTransition(.symbolEffect(.replace.downUp.byLayer, options: .nonRepeating)) 395 + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 358 396 Text("\(gallery.favCount ?? 0)") 359 397 } 360 398 } 361 399 .foregroundStyle(isFavorited ? Color("AccentColor") : .secondary) 400 + .overlay(alignment: .leading) { 401 + ZStack { 402 + ForEach(likeParticleBursts, id: \.self) { _ in 403 + ForEach(0 ..< 5, id: \.self) { i in 404 + LikeParticleView(index: i) 405 + } 406 + } 407 + } 408 + .offset(x: 11) 409 + .allowsHitTesting(false) 410 + } 362 411 363 412 Button { 364 413 onNavigate() ··· 374 423 ShareLink(item: galleryShareURL) { 375 424 Image(systemName: "paperplane") 376 425 .font(.system(size: 20)) 377 - .rotationEffect(.degrees(shareWiggle ? -15 : 0)) 426 + .rotationEffect(.degrees(shareAnimating ? -15 : 0)) 378 427 .animation( 379 - shareWiggle 428 + shareAnimating 380 429 ? .easeInOut(duration: 0.08).repeatCount(5, autoreverses: true) 381 430 : .default, 382 - value: shareWiggle 431 + value: shareAnimating 383 432 ) 384 433 } 385 434 .foregroundStyle(.secondary) 386 - .disabled(didLongPressShare) 435 + .disabled(shareAnimating) 387 436 .simultaneousGesture( 388 437 LongPressGesture(minimumDuration: 0.5) 389 438 .onEnded { _ in 390 - didLongPressShare = true 391 439 UIPasteboard.general.url = galleryShareURL 392 - let generator = UIImpactFeedbackGenerator(style: .medium) 393 - generator.impactOccurred() 394 - shareWiggle = true 395 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 396 - shareWiggle = false 440 + UIImpactFeedbackGenerator(style: .medium).impactOccurred() 441 + shareAnimating = true 442 + showCopiedToast = true 443 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 444 + shareAnimating = false 397 445 } 398 - showCopiedToast = true 399 446 DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 400 447 showCopiedToast = false 401 448 } 402 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 403 - didLongPressShare = false 404 - } 405 449 } 406 450 ) 407 451 408 452 Spacer() 409 453 } 410 454 .font(.subheadline) 455 + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: gallery.favCount) 411 456 .padding(.horizontal, 12) 412 457 .padding(.top, 12) 413 458 .padding(.bottom, 4) ··· 467 512 } 468 513 } 469 514 515 + private func addParticleBurst() { 516 + let id = UUID() 517 + likeParticleBursts.append(id) 518 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.3) { 519 + likeParticleBursts.removeAll { $0 == id } 520 + } 521 + } 522 + 470 523 private func prefetchCarousel(photos: [GrainPhoto], page: Int) { 471 524 let input = photos.map { (thumb: $0.thumb, fullsize: $0.fullsize) } 472 525 let plan = ImagePrefetchPlanning.carouselPrefetchRequests(photos: input, currentPage: page) ··· 475 528 476 529 private func doubleTapLike(at point: CGPoint) { 477 530 hearts.append(HeartAnimationState(position: point)) 478 - 531 + addParticleBurst() 479 532 guard !isFavorited, !isFavoriting else { return } 533 + triggerFavoriteToggle() 534 + } 535 + 536 + private func triggerFavoriteToggle() { 480 537 isFavoriting = true 481 538 Task { 482 539 await toggleFavorite()
+28 -24
Grain/Views/Components/ZoomableImage.swift
··· 159 159 @State private var snapBackTask: Task<Void, Never>? 160 160 161 161 var body: some View { 162 - LazyImage(request: ImageRequest(url: URL(string: url), priority: .veryHigh)) { state in 163 - if let image = state.image { 164 - image 165 - .resizable() 166 - .aspectRatio(aspectRatio, contentMode: .fit) 167 - } else if let thumbURL { 168 - LazyImage(url: URL(string: thumbURL)) { thumbState in 169 - if let thumb = thumbState.image { 170 - thumb 171 - .resizable() 172 - .aspectRatio(aspectRatio, contentMode: .fit) 173 - .blur(radius: 20) 174 - .clipped() 175 - } else { 176 - Rectangle() 177 - .fill(.quaternary) 178 - .aspectRatio(aspectRatio, contentMode: .fit) 162 + // PinchZoomOverlay is a ZStack sibling (not an overlay on LazyImage) so the 163 + // UIView fills the full carousel slot — black-bar areas are tappable and 164 + // UIKit coordinates already map to the carousel ZStack space with no correction. 165 + ZStack { 166 + LazyImage(request: ImageRequest(url: URL(string: url), priority: .veryHigh)) { state in 167 + if let image = state.image { 168 + image 169 + .resizable() 170 + .aspectRatio(aspectRatio, contentMode: .fit) 171 + } else if let thumbURL { 172 + LazyImage(url: URL(string: thumbURL)) { thumbState in 173 + if let thumb = thumbState.image { 174 + thumb 175 + .resizable() 176 + .aspectRatio(aspectRatio, contentMode: .fit) 177 + .blur(radius: 20) 178 + .clipped() 179 + } else { 180 + Rectangle() 181 + .fill(.quaternary) 182 + .aspectRatio(aspectRatio, contentMode: .fit) 183 + } 179 184 } 185 + } else { 186 + Rectangle() 187 + .fill(.quaternary) 188 + .aspectRatio(aspectRatio, contentMode: .fit) 180 189 } 181 - } else { 182 - Rectangle() 183 - .fill(.quaternary) 184 - .aspectRatio(aspectRatio, contentMode: .fit) 185 190 } 186 - } 187 - .opacity(zoomState?.showOverlay == true && zoomState?.imageURL == url ? 0 : 1) 188 - .overlay { 191 + .opacity(zoomState?.showOverlay == true && zoomState?.imageURL == url ? 0 : 1) 192 + 189 193 if let zoomState { 190 194 PinchZoomOverlay( 191 195 zoomState: zoomState,