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.

fix: rewrite OverlappingAvatarsView in pure SwiftUI

Replace ~140-line UIKit UIViewRepresentable with a simple HStack using
negative spacing and the existing AvatarView component.

+15 -126
+15 -126
Grain/Views/Notifications/NotificationsView.swift
··· 374 374 } 375 375 } 376 376 377 - // MARK: - Overlapping Avatars (UIKit-backed, zero SwiftUI layout participation) 377 + // MARK: - Overlapping Avatars 378 378 379 - private struct OverlappingAvatarsView: UIViewRepresentable { 379 + private struct OverlappingAvatarsView: View { 380 380 let authors: [GrainProfile] 381 381 let size: CGFloat 382 382 let overlap: CGFloat 383 383 var onProfileTap: ((String) -> Void)? 384 384 385 - private var totalWidth: CGFloat { 386 - guard !authors.isEmpty else { return 0 } 387 - return size + CGFloat(authors.count - 1) * (size - overlap) 388 - } 389 - 390 - func makeUIView(context _: Context) -> OverlappingAvatarsUIView { 391 - let view = OverlappingAvatarsUIView() 392 - view.onProfileTap = onProfileTap 393 - view.configure(authors: authors, size: size, overlap: overlap) 394 - return view 395 - } 396 - 397 - func updateUIView(_ uiView: OverlappingAvatarsUIView, context _: Context) { 398 - uiView.onProfileTap = onProfileTap 399 - uiView.configure(authors: authors, size: size, overlap: overlap) 385 + private var step: CGFloat { 386 + size - overlap 400 387 } 401 388 402 - func sizeThatFits(_: ProposedViewSize, uiView _: OverlappingAvatarsUIView, context _: Context) -> CGSize? { 403 - CGSize(width: totalWidth, height: size) 404 - } 405 - } 406 - 407 - final class OverlappingAvatarsUIView: UIView { 408 - var onProfileTap: ((String) -> Void)? 409 - private var avatarViews: [UIImageView] = [] 410 - private var authorDids: [String] = [] 411 - private var currentKey = "" 412 - 413 - private static func makeFallbackImage(size: CGFloat) -> UIImage { 414 - let iconSize = size * 0.45 415 - let config = UIImage.SymbolConfiguration(pointSize: iconSize) 416 - let symbol = UIImage(systemName: "person.fill", withConfiguration: config)! 417 - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) 418 - return renderer.image { ctx in 419 - UIColor.systemGray4.setFill() 420 - ctx.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) 421 - let tinted = symbol.withTintColor(.systemGray2, renderingMode: .alwaysOriginal) 422 - let origin = CGPoint(x: (size - tinted.size.width) / 2, y: (size - tinted.size.height) / 2) 423 - tinted.draw(at: origin) 424 - } 425 - } 426 - 427 - override init(frame: CGRect) { 428 - super.init(frame: frame) 429 - isUserInteractionEnabled = true 430 - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: OverlappingAvatarsUIView, _) in 431 - for iv in view.avatarViews { 432 - iv.layer.borderColor = UIColor.systemBackground.cgColor 433 - } 434 - } 435 - } 436 - 437 - @available(*, unavailable) 438 - required init?(coder _: NSCoder) { 439 - fatalError() 440 - } 441 - 442 - func configure(authors: [GrainProfile], size: CGFloat, overlap: CGFloat) { 443 - let key = authors.map(\.did).joined(separator: ",") 444 - guard key != currentKey else { return } 445 - currentKey = key 446 - authorDids = authors.map(\.did) 447 - 448 - // Remove old views 449 - avatarViews.forEach { $0.removeFromSuperview() } 450 - avatarViews.removeAll() 451 - 452 - let step = size - overlap 453 - let fallback = Self.makeFallbackImage(size: size) 454 - 455 - for (i, author) in authors.enumerated() { 456 - let iv = UIImageView() 457 - iv.contentMode = .scaleAspectFill 458 - iv.clipsToBounds = true 459 - iv.layer.cornerRadius = size / 2 460 - iv.layer.borderWidth = 2 461 - iv.layer.borderColor = UIColor.systemBackground.cgColor 462 - iv.frame = CGRect(x: CGFloat(i) * step, y: 0, width: size, height: size) 463 - 464 - // Load from Nuke memory cache synchronously, or fetch async 465 - if let url = author.avatar, let imageURL = URL(string: url) { 466 - let request = ImageRequest(url: imageURL) 467 - if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image { 468 - iv.image = cached 469 - } else { 470 - iv.image = fallback 471 - Task { @MainActor in 472 - if let image = try? await ImagePipeline.shared.image(for: request) { 473 - iv.image = image 474 - } 475 - } 389 + var body: some View { 390 + HStack(spacing: -overlap) { 391 + ForEach(Array(authors.enumerated()), id: \.element.did) { i, author in 392 + Button { 393 + onProfileTap?(author.did) 394 + } label: { 395 + AvatarView(url: author.avatar, size: size, animated: false) 396 + .overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 2)) 476 397 } 477 - } else { 478 - iv.image = fallback 479 - } 480 - 481 - addSubview(iv) 482 - avatarViews.append(iv) 483 - } 484 - 485 - let totalWidth = size + CGFloat(authors.count - 1) * step 486 - frame.size = CGSize(width: totalWidth, height: size) 487 - invalidateIntrinsicContentSize() 488 - } 489 - 490 - override var intrinsicContentSize: CGSize { 491 - frame.size 492 - } 493 - 494 - override func hitTest(_ point: CGPoint, with _: UIEvent?) -> UIView? { 495 - // Claim hit for any touch inside an avatar circle 496 - for iv in avatarViews.reversed() { 497 - if iv.frame.contains(point) { return self } 498 - } 499 - return nil 500 - } 501 - 502 - override func touchesEnded(_ touches: Set<UITouch>, with _: UIEvent?) { 503 - guard let touch = touches.first else { return } 504 - let location = touch.location(in: self) 505 - // Check avatars in reverse order (topmost first) 506 - for (i, iv) in avatarViews.enumerated().reversed() { 507 - if iv.frame.contains(location) { 508 - if i < authorDids.count { 509 - onProfileTap?(authorDids[i]) 510 - } 511 - return 398 + .buttonStyle(.plain) 399 + .zIndex(Double(authors.count - i)) 512 400 } 513 401 } 402 + .fixedSize() 514 403 } 515 404 } 516 405