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: story viewer UX improvements

- Fix heart icon carrying over filled state when swiping between stories
- Open comment sheet to medium detent only (not full screen)
- Improve reply banner visibility with larger text and proper hit area
- Swipe up on story to open comment sheet
- Remove tap-to-navigate on gallery title
- Resume timer when swiping right on first story

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

+40 -17
+1
Grain/ViewModels/StoryCommentPresenter.swift
··· 90 90 .environment(auth) 91 91 .environment(storyStatusCache) 92 92 .environment(viewedStories) 93 + .presentationDetents([.medium]) 93 94 // Disables iOS's own dim + the default "tap dim to expand detent" 94 95 // behavior. We draw our own dim above (tappable to dismiss), and 95 96 // this also lets background taps reach it.
+16 -7
Grain/Views/Comments/CommentSheetContent.swift
··· 135 135 VStack(spacing: 0) { 136 136 if let replyTarget = replyingTo { 137 137 HStack { 138 - Text("Replying to @\(replyTarget.author.handle)") 139 - .font(.caption) 138 + Image(systemName: "arrowshape.turn.up.left.fill") 139 + .font(.caption2) 140 + .foregroundStyle(.primary) 141 + Text("Replying to") 140 142 .foregroundStyle(.secondary) 143 + Text("@\(replyTarget.author.handle)") 144 + .foregroundStyle(.primary) 141 145 Spacer() 142 146 Button { 143 147 replyingTo = nil 144 148 } label: { 145 - Image(systemName: "xmark.circle.fill") 146 - .font(.caption) 147 - .foregroundStyle(.secondary) 149 + Image(systemName: "xmark") 150 + .font(.caption.weight(.semibold)) 151 + .foregroundStyle(Color("AccentColor")) 152 + .frame(width: 32, height: 32) 153 + .contentShape(Rectangle()) 148 154 } 155 + .buttonStyle(.plain) 149 156 } 150 - .padding(.horizontal, 16) 151 - .padding(.top, 4) 157 + .font(.subheadline.weight(.medium)) 158 + .padding(.leading, 16) 159 + .padding(.trailing, 8) 160 + .padding(.top, 6) 152 161 } 153 162 154 163 GlassEffectContainer(spacing: 8) {
-2
Grain/Views/Components/GalleryCardView.swift
··· 542 542 Text(gallery.title ?? "") 543 543 .font(.subheadline.weight(.semibold)) 544 544 .lineLimit(3) 545 - .contentShape(Rectangle()) 546 - .onTapGesture { onNavigate() } 547 545 548 546 if let description = gallery.description, !description.isEmpty { 549 547 ExpandableDescriptionView(
+12
Grain/Views/Stories/DragToDismiss.swift
··· 42 42 let onDragCancel: () -> Void 43 43 let onSwipeLeft: () -> Void 44 44 let onSwipeRight: () -> Void 45 + var onSwipeUp: (() -> Void)? 45 46 var onHorizontalDragStart: ((Bool) -> Void)? // true = swiping left (forward) 46 47 var onSwipeDragging: ((CGFloat) -> Void)? // raw translation.x during drag 47 48 var onHorizontalDragCancel: (() -> Void)? ··· 66 67 context.coordinator.onDragCancel = onDragCancel 67 68 context.coordinator.onSwipeLeft = onSwipeLeft 68 69 context.coordinator.onSwipeRight = onSwipeRight 70 + context.coordinator.onSwipeUp = onSwipeUp 69 71 context.coordinator.onHorizontalDragStart = onHorizontalDragStart 70 72 context.coordinator.onSwipeDragging = onSwipeDragging 71 73 context.coordinator.onHorizontalDragCancel = onHorizontalDragCancel ··· 98 100 var onDragCancel: () -> Void 99 101 var onSwipeLeft: () -> Void 100 102 var onSwipeRight: () -> Void 103 + var onSwipeUp: (() -> Void)? 101 104 var onHorizontalDragStart: ((Bool) -> Void)? 102 105 var onSwipeDragging: ((CGFloat) -> Void)? 103 106 var onHorizontalDragCancel: (() -> Void)? ··· 124 127 self.onDragCancel = onDragCancel 125 128 self.onSwipeLeft = onSwipeLeft 126 129 self.onSwipeRight = onSwipeRight 130 + onSwipeUp = nil 127 131 self.onHorizontalDragStart = onHorizontalDragStart 128 132 self.onSwipeDragging = onSwipeDragging 129 133 self.onHorizontalDragCancel = onHorizontalDragCancel ··· 169 173 if absY > absX, translation.y > 0 { 170 174 direction = .vertical 171 175 onDragStart() 176 + } else if absY > absX, translation.y < 0, onSwipeUp != nil { 177 + direction = .vertical 172 178 } else if absX > absY { 173 179 direction = .horizontal 174 180 onHorizontalDragStart?(translation.x < 0) ··· 189 195 } 190 196 191 197 case .ended, .cancelled: 198 + if direction == .vertical, translation.y < -80 || velocity.y < -500 { 199 + onSwipeUp?() 200 + direction = .none 201 + return 202 + } 203 + 192 204 if direction == .vertical { 193 205 let ty = max(translation.y, 0) 194 206
+11 -8
Grain/Views/Stories/StoryViewer.swift
··· 216 216 onDragCancel: { startTimerIfSafe() }, 217 217 onSwipeLeft: { goToNextAuthor() }, 218 218 onSwipeRight: { goToPreviousAuthor() }, 219 + onSwipeUp: { openCommentSheet(focusInput: false) }, 219 220 onHorizontalDragStart: { forward in beginSwipe(forward: forward) }, 220 221 onSwipeDragging: { tx in updateSwipeDrag(tx) }, 221 222 onHorizontalDragCancel: { cancelSwipe() }, ··· 755 756 } 756 757 i -= 1 757 758 } 759 + // No previous author found — resume the timer 760 + startTimerIfSafe() 758 761 } 759 762 760 763 private func beginSwipe(forward: Bool) { ··· 1065 1068 return story.viewer?.fav != nil 1066 1069 } 1067 1070 1068 - private func bottomInputBar(interactive: Bool, story _: GrainStory?) -> some View { 1069 - HStack(spacing: 12) { 1071 + private func bottomInputBar(interactive: Bool, story: GrainStory?) -> some View { 1072 + let favState = interactive ? isFavorited : (story?.viewer?.fav != nil) 1073 + return HStack(spacing: 12) { 1070 1074 // "Add a comment..." — opens sheet with keyboard 1071 1075 if interactive { 1072 1076 Button { ··· 1095 1099 // Heart — favorite/unfavorite 1096 1100 if interactive { 1097 1101 Button { 1098 - if !isFavorited { heartBeatTrigger &+= 1 } 1102 + if !favState { heartBeatTrigger &+= 1 } 1099 1103 triggerFavoriteToggle() 1100 1104 } label: { 1101 - heartIcon(isFavorited: isFavorited) 1105 + heartIcon(isFavorited: favState) 1102 1106 .contentShape(Rectangle()) 1103 1107 } 1104 1108 .buttonStyle(.plain) 1105 1109 } else { 1106 - heartIcon(isFavorited: isFavorited) 1107 - .onChange(of: isFavorited) { oldValue, newValue in 1108 - if !oldValue, newValue { heartBeatTrigger &+= 1 } 1110 + heartIcon(isFavorited: favState) 1111 + .onChange(of: favState) { oldValue, newValue in 1112 + if oldValue != true, newValue == true { heartBeatTrigger &+= 1 } 1109 1113 } 1110 1114 } 1111 1115 } ··· 1117 1121 Image(systemName: isFavorited ? "heart.fill" : "heart") 1118 1122 .font(.title) 1119 1123 .foregroundStyle(isFavorited ? Color("AccentColor") : .white) 1120 - .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 1121 1124 .keyframeAnimator(initialValue: 1.0, trigger: heartBeatTrigger) { content, scale in 1122 1125 content.scaleEffect(scale) 1123 1126 } keyframes: { _ in