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: drop avatar morph, fade image during swipe-to-dismiss

The matched-geometry morph was broken on open and popping on close.
Replace it with a plain opacity transition, and drive both the backdrop
dim and the image opacity from the live drag distance so the photo
smoothly fades as it's swiped away. Commit the final translation on
dismiss so @GestureState's reset doesn't flash the image back to solid
before the removal transition runs.

Also fix an unrelated ImageZoomState typo in FeedTabContent.

authored by

Hima Aramona and committed by
Chad Miller
29f5b254 76a0e282

+19 -33
+1 -1
Grain/Views/Feed/FeedView.swift
··· 226 226 @State private var selectedHashtag: String? 227 227 @State private var selectedLocation: LocationDestination? 228 228 @State private var deletedGalleryUri: String? 229 - @State private var zoomState = ImageZoaomState() 229 + @State private var zoomState = ImageZoomState() 230 230 @State private var cardStoryAuthor: GrainStoryAuthor? 231 231 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 232 232 @State private var suggestedFollows: [SuggestedItem] = []
+18 -32
Grain/Views/Profile/ProfileView.swift
··· 12 12 struct ProfileView: View { 13 13 @Namespace private var viewModeNS 14 14 @Namespace private var galleryZoomNS 15 - @Namespace private var avatarZoomNS 16 15 @Environment(AuthManager.self) private var auth 17 16 @Environment(ViewedStoryStorage.self) private var viewedStories 18 17 @Environment(LabelDefinitionsCache.self) private var labelDefsCache ··· 29 28 @State private var tabScrollOffsetX: CGFloat = 0 30 29 @State private var tabSectionViewportMinY: CGFloat = .infinity 31 30 @State private var zoomState = ImageZoomState() 32 - // Flipped on during a dismiss so the overlay disappears via a plain opacity 33 - // transition instead of morphing back to the avatar anchor. Reset after the 34 - // overlay is gone so the next open can morph again. 35 - @State private var suppressAvatarMorph = false 36 31 @State private var cardStoryAuthor: GrainStoryAuthor? 37 32 let client: XRPCClient 38 33 @State private var selectedArchivedStory: GrainStory? ··· 67 62 } 68 63 69 64 if showAvatarOverlay, let avatar = viewModel.profile?.avatar { 70 - AvatarOverlay( 71 - url: avatar, 72 - namespace: suppressAvatarMorph ? nil : avatarZoomNS, 73 - onDismiss: dismissAvatarOverlay 74 - ) 75 - .ignoresSafeArea() 76 - .zIndex(999) 77 - .transition(.asymmetric(insertion: .identity, removal: .opacity)) 65 + AvatarOverlay(url: avatar, onDismiss: dismissAvatarOverlay) 66 + .ignoresSafeArea() 67 + .zIndex(999) 68 + .transition(.opacity) 78 69 } 79 70 } 80 71 } ··· 89 80 ) { 90 81 AvatarView(url: profile.avatar, size: 80) 91 82 .liquidGlassCircle() 92 - .matchedGeometryEffect(id: "avatar", in: avatarZoomNS, isSource: !showAvatarOverlay) 93 83 } 94 84 .overlay(alignment: .bottomTrailing) { 95 85 if did == auth.userDID { ··· 911 901 } 912 902 913 903 private func openAvatarOverlay() { 914 - suppressAvatarMorph = false 915 - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { 904 + withAnimation(.easeOut(duration: 0.2)) { 916 905 showAvatarOverlay = true 917 906 } 918 907 } 919 908 920 909 private func dismissAvatarOverlay() { 921 - // Detach matchedGeometryEffect before the removal transition so the overlay 922 - // fades instead of morphing back into the avatar anchor. The flag resets 923 - // after the transition so the next open still animates from the avatar. 924 - suppressAvatarMorph = true 925 910 withAnimation(.easeOut(duration: 0.25)) { 926 911 showAvatarOverlay = false 927 912 } 928 - Task { @MainActor in 929 - try? await Task.sleep(for: .milliseconds(300)) 930 - suppressAvatarMorph = false 931 - } 932 913 } 933 914 } 934 915 935 916 struct AvatarOverlay: View { 936 917 let url: String 937 - var namespace: Namespace.ID? 938 918 let onDismiss: () -> Void 939 919 940 920 @State private var zoomState = ImageZoomState() ··· 946 926 dragOffset + dragDelta 947 927 } 948 928 949 - private var backgroundOpacity: Double { 950 - guard !zoomState.showOverlay else { return 0.92 } 951 - return max(0, 0.92 - abs(liveDrag) / 250) 929 + /// Fades both the background dim and the image itself as the user swipes 930 + /// the overlay away. At 250pt of drag, everything is fully transparent. 931 + private var dragProgress: Double { 932 + guard !zoomState.showOverlay else { return 0 } 933 + return min(1, Double(abs(liveDrag)) / 250) 952 934 } 953 935 954 936 var body: some View { 955 937 ZStack { 956 938 Color.black 957 - .opacity(backgroundOpacity) 939 + .opacity(0.92 * (1 - dragProgress)) 958 940 .ignoresSafeArea() 959 941 .contentShape(Rectangle()) 960 942 .onTapGesture { onDismiss() } ··· 982 964 } 983 965 .position(x: geo.size.width / 2, y: geo.size.height / 2) 984 966 .offset(y: liveDrag) 985 - .if(namespace != nil) { view in 986 - view.matchedGeometryEffect(id: "avatar", in: namespace!, isSource: false) 987 - } 967 + .opacity(1 - dragProgress) 988 968 } 989 969 .ignoresSafeArea() 990 970 } ··· 1002 982 let shouldDismiss = abs(val.translation.height) > 80 1003 983 || abs(val.predictedEndTranslation.height) > 150 1004 984 if shouldDismiss { 985 + // Commit the drag translation to @State so the image stays 986 + // at its dragged opacity while the removal transition runs — 987 + // otherwise @GestureState dragDelta resets to 0 in the same 988 + // frame and the image pops back to full opacity for a beat 989 + // before fading out. 990 + dragOffset = val.translation.height 1005 991 onDismiss() 1006 992 } else { 1007 993 withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {