From 0926cc2de20569c399e55bcadf2c8accdd89f1f2 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 5 Dec 2025 13:40:16 +0200 Subject: [PATCH 1/7] Fix scrolling in the message list when presented with a sheet on iOS 26 --- CHANGELOG.md | 3 +- .../MessageList/MessageContainerView.swift | 51 ++++++------------- .../MessageList/MessageListView.swift | 39 ++++++++++++++ 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca98c61..689cdb6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### 🐞 Fixed +- Fix scrolling in the message list when presented with a sheet on iOS 26 [#1065](https://github.com/GetStream/stream-chat-swiftui/pull/1065) # [4.94.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.94.0) _December 02, 2025_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index cf9324c9..048db492 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -10,6 +10,7 @@ public struct MessageContainerView: View { @StateObject var messageViewModel: MessageViewModel @Environment(\.channelTranslationLanguage) var translationLanguage @Environment(\.highlightedMessageId) var highlightedMessageId + @Environment(\.messageListSwipe) var messageListSwipe @Injected(\.fonts) private var fonts @Injected(\.colors) private var colors @@ -32,7 +33,6 @@ public struct MessageContainerView: View { @State private var computeFrame = false @State private var offsetX: CGFloat = 0 @State private var offsetYAvatar: CGFloat = 0 - @GestureState private var offset: CGSize = .zero private let replyThreshold: CGFloat = 60 private var paddingValue: CGFloat { @@ -129,6 +129,9 @@ public struct MessageContainerView: View { .onChange(of: computeFrame, perform: { _ in frame = proxy.frame(in: .global) }) + .onChange(of: messageListSwipe, perform: { messageListSwipe in + handleMessageListSwipe(messageListSwipe, geometry: proxy) + }) } ) .onTapGesture(count: 2) { @@ -140,40 +143,6 @@ public struct MessageContainerView: View { handleGestureForMessage(showsMessageActions: true) }) .offset(x: min(self.offsetX, maximumHorizontalSwipeDisplacement)) - .simultaneousGesture( - DragGesture( - minimumDistance: minimumSwipeDistance, - coordinateSpace: .local - ) - .updating($offset) { (value, gestureState, _) in - guard messageViewModel.isSwipeToQuoteReplyPossible else { - return - } - // Using updating since onEnded is not called if the gesture is canceled. - let diff = CGSize( - width: value.location.x - value.startLocation.x, - height: value.location.y - value.startLocation.y - ) - - if diff == .zero { - gestureState = .zero - } else { - gestureState = value.translation - } - } - ) - .onChange(of: offset, perform: { _ in - if !channel.config.quotesEnabled { - return - } - - if offset == .zero { - // gesture ended or cancelled - setOffsetX(value: 0) - } else { - dragChanged(to: offset.width) - } - }) .accessibilityElement(children: .contain) .accessibilityIdentifier("MessageView") @@ -351,6 +320,18 @@ public struct MessageContainerView: View { private var messageListConfig: MessageListConfig { utils.messageListConfig } + + private func handleMessageListSwipe(_ messageListSwipe: MessageListSwipe?, geometry: GeometryProxy) { + guard messageViewModel.isSwipeToQuoteReplyPossible else { return } + guard let messageListSwipe else { return } + // The view is moving during the swipe handling, therefore we skip the contains check if it is in progress + guard offsetX > 0 || geometry.frame(in: .global).contains(messageListSwipe.startLocation) else { return } + if messageListSwipe.horizontalOffset == 0 { + setOffsetX(value: 0) + } else { + dragChanged(to: messageListSwipe.horizontalOffset) + } + } private func dragChanged(to value: CGFloat) { let horizontalTranslation = value diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index c2c0e6d0..fe208e1b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -36,6 +36,7 @@ public struct MessageListView: View, KeyboardReadable { @State private var scrollDirection = ScrollDirection.up @State private var unreadMessagesBannerShown = false @State private var unreadButtonDismissed = false + @State private var messageListSwipe: MessageListSwipe? private var messageRenderingUtil = MessageRenderingUtil.shared private var skipRenderingMessageIds = [String]() @@ -191,6 +192,7 @@ public struct MessageListView: View, KeyboardReadable { isLast: !showsLastInGroupInfo && message == messages.last ) .environment(\.channelTranslationLanguage, channel.membership?.language) + .environment(\.messageListSwipe, messageListSwipe) .onAppear { if index == nil { index = messageListDateUtils.index(for: message, in: messages) @@ -310,6 +312,20 @@ public struct MessageListView: View, KeyboardReadable { } } } + .if(channel.config.quotesEnabled, transform: { view in + view.simultaneousGesture( + DragGesture( + minimumDistance: utils.messageListConfig.messageDisplayOptions.minimumSwipeGestureDistance, + coordinateSpace: .global + ) + .onChanged { value in + messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: value.translation.width) + } + .onEnded { value in + messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: 0) + } + ) + }) .accessibilityIdentifier("MessageListScrollView") } @@ -651,6 +667,15 @@ private struct MessageViewModelKey: EnvironmentKey { static let defaultValue: MessageViewModel? = nil } +private struct MessageListSwipeKey: EnvironmentKey { + static let defaultValue: MessageListSwipe? = nil +} + +struct MessageListSwipe: Equatable { + let startLocation: CGPoint + let horizontalOffset: CGFloat +} + extension EnvironmentValues { var channelTranslationLanguage: TranslationLanguage? { get { @@ -669,4 +694,18 @@ extension EnvironmentValues { self[MessageViewModelKey.self] = newValue } } + + /// Propagates the drag state to message items. + /// + /// - Important: Since iOS 26 simultaneous gestures do not update ancestors. + /// The gesture handler should be attached to the ScrollView and then propagating + /// the state to items which decide if the drag should be handled. + var messageListSwipe: MessageListSwipe? { + get { + self[MessageListSwipeKey.self] + } + set { + self[MessageListSwipeKey.self] = newValue + } + } } From f20bb76376d6553ac0bc8e16d249d2e8b6d6e4ba Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 15 Dec 2025 16:48:52 +0200 Subject: [PATCH 2/7] Reduce swipe action updates --- .../ChatChannel/MessageList/MessageListView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index fe208e1b..2d92e7b1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -319,9 +319,12 @@ public struct MessageListView: View, KeyboardReadable { coordinateSpace: .global ) .onChanged { value in + guard abs(value.translation.width) > abs(value.translation.height) else { return } + guard value.translation.width != messageListSwipe?.horizontalOffset else { return } messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: value.translation.width) } .onEnded { value in + guard let offset = messageListSwipe?.horizontalOffset, offset != 0 else { return } messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: 0) } ) From 44d9077eb4d008fe007edeba7cbb95ecd60cb2ec Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 16 Dec 2025 11:38:26 +0200 Subject: [PATCH 3/7] Fine tune swipe when scrolling up and down --- .../ChatChannel/MessageList/MessageContainerView.swift | 9 ++++++++- .../ChatChannel/MessageList/MessageListView.swift | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index 048db492..be9094fa 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -323,7 +323,14 @@ public struct MessageContainerView: View { private func handleMessageListSwipe(_ messageListSwipe: MessageListSwipe?, geometry: GeometryProxy) { guard messageViewModel.isSwipeToQuoteReplyPossible else { return } - guard let messageListSwipe else { return } + guard let messageListSwipe else { + setOffsetX(value: 0) + return + } + if quotedMessage == message { + setOffsetX(value: 0) + return + } // The view is moving during the swipe handling, therefore we skip the contains check if it is in progress guard offsetX > 0 || geometry.frame(in: .global).contains(messageListSwipe.startLocation) else { return } if messageListSwipe.horizontalOffset == 0 { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 2d92e7b1..7410ad8c 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -319,13 +319,14 @@ public struct MessageListView: View, KeyboardReadable { coordinateSpace: .global ) .onChanged { value in + guard value.velocity == .zero || abs(value.velocity.width) > abs(value.velocity.height) else { return } guard abs(value.translation.width) > abs(value.translation.height) else { return } guard value.translation.width != messageListSwipe?.horizontalOffset else { return } messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: value.translation.width) } - .onEnded { value in + .onEnded { _ in guard let offset = messageListSwipe?.horizontalOffset, offset != 0 else { return } - messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: 0) + messageListSwipe = nil } ) }) From 788da0a141b4cfd966c0e732b3fa4899754ca1fd Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 16 Dec 2025 13:58:48 +0200 Subject: [PATCH 4/7] Adjust some more --- .../ChatChannel/MessageList/MessageContainerView.swift | 6 ++++-- .../ChatChannel/MessageList/MessageListView.swift | 1 + Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index be9094fa..9b0557f8 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -331,8 +331,10 @@ public struct MessageContainerView: View { setOffsetX(value: 0) return } - // The view is moving during the swipe handling, therefore we skip the contains check if it is in progress - guard offsetX > 0 || geometry.frame(in: .global).contains(messageListSwipe.startLocation) else { return } + if utils.messageCachingUtils.swipeToReplyId == nil, geometry.frame(in: .global).contains(messageListSwipe.startLocation) { + utils.messageCachingUtils.swipeToReplyId = message.id + } + guard utils.messageCachingUtils.swipeToReplyId == message.id else { return } if messageListSwipe.horizontalOffset == 0 { setOffsetX(value: 0) } else { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 7410ad8c..22637b72 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -326,6 +326,7 @@ public struct MessageListView: View, KeyboardReadable { } .onEnded { _ in guard let offset = messageListSwipe?.horizontalOffset, offset != 0 else { return } + utils.messageCachingUtils.swipeToReplyId = nil messageListSwipe = nil } ) diff --git a/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift b/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift index 7c4d3ac5..f65b6fba 100644 --- a/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift +++ b/Sources/StreamChatSwiftUI/Utils/MessageCachingUtils.swift @@ -19,11 +19,14 @@ class MessageCachingUtils { } var jumpToReplyId: String? + + var swipeToReplyId: MessageId? func clearCache() { log.debug("Clearing cached message data") scrollOffset = 0 messageThreadShown = false + swipeToReplyId = nil } } From 9f04e3f1cc7941fb3b933dcca353441fee09baaf Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 18 Dec 2025 10:55:56 +0200 Subject: [PATCH 5/7] Allow swiping swiped message --- .../ChatChannel/MessageList/MessageContainerView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index 9b0557f8..4b01cf69 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -327,10 +327,6 @@ public struct MessageContainerView: View { setOffsetX(value: 0) return } - if quotedMessage == message { - setOffsetX(value: 0) - return - } if utils.messageCachingUtils.swipeToReplyId == nil, geometry.frame(in: .global).contains(messageListSwipe.startLocation) { utils.messageCachingUtils.swipeToReplyId = message.id } From 6f113b16eb26edfb495256efc303d5d1ea567bad Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 18 Dec 2025 12:38:39 +0200 Subject: [PATCH 6/7] Fine tune some more --- .../MessageList/MessageContainerView.swift | 13 +++++++++++-- .../ChatChannel/MessageList/MessageListView.swift | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index 4b01cf69..10feae92 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -324,13 +324,22 @@ public struct MessageContainerView: View { private func handleMessageListSwipe(_ messageListSwipe: MessageListSwipe?, geometry: GeometryProxy) { guard messageViewModel.isSwipeToQuoteReplyPossible else { return } guard let messageListSwipe else { - setOffsetX(value: 0) + // Swiping ended, reset the offset + if offsetX != 0 { + setOffsetX(value: 0) + } return } - if utils.messageCachingUtils.swipeToReplyId == nil, geometry.frame(in: .global).contains(messageListSwipe.startLocation) { + // Keep track of which message is handling the swipe (only should start when dragging from the message content) + let currentFrame = geometry.frame(in: .global) + if utils.messageCachingUtils.swipeToReplyId == nil, currentFrame.contains(messageListSwipe.startLocation) { utils.messageCachingUtils.swipeToReplyId = message.id } guard utils.messageCachingUtils.swipeToReplyId == message.id else { return } + + // When dragging moves outside of the message row, stop handling swiping + guard messageListSwipe.currentLocation.y <= currentFrame.maxY, + messageListSwipe.currentLocation.y >= currentFrame.minY else { return } if messageListSwipe.horizontalOffset == 0 { setOffsetX(value: 0) } else { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 22637b72..4780a1e7 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -322,10 +322,15 @@ public struct MessageListView: View, KeyboardReadable { guard value.velocity == .zero || abs(value.velocity.width) > abs(value.velocity.height) else { return } guard abs(value.translation.width) > abs(value.translation.height) else { return } guard value.translation.width != messageListSwipe?.horizontalOffset else { return } - messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: value.translation.width) + messageListSwipe = MessageListSwipe( + startLocation: value.startLocation, + currentLocation: value.location, + horizontalOffset: value.translation.width + ) } .onEnded { _ in - guard let offset = messageListSwipe?.horizontalOffset, offset != 0 else { return } + guard let offset = messageListSwipe?.horizontalOffset, + offset != 0 else { return } utils.messageCachingUtils.swipeToReplyId = nil messageListSwipe = nil } @@ -678,6 +683,7 @@ private struct MessageListSwipeKey: EnvironmentKey { struct MessageListSwipe: Equatable { let startLocation: CGPoint + let currentLocation: CGPoint let horizontalOffset: CGFloat } From bbccfd411275c4508f0da18fe1ec6e2f5ed2fdde Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 18 Dec 2025 13:36:17 +0200 Subject: [PATCH 7/7] Revert "Fine tune some more" This reverts commit 6f113b16eb26edfb495256efc303d5d1ea567bad. --- .../MessageList/MessageContainerView.swift | 13 ++----------- .../ChatChannel/MessageList/MessageListView.swift | 10 ++-------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift index 10feae92..4b01cf69 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageContainerView.swift @@ -324,22 +324,13 @@ public struct MessageContainerView: View { private func handleMessageListSwipe(_ messageListSwipe: MessageListSwipe?, geometry: GeometryProxy) { guard messageViewModel.isSwipeToQuoteReplyPossible else { return } guard let messageListSwipe else { - // Swiping ended, reset the offset - if offsetX != 0 { - setOffsetX(value: 0) - } + setOffsetX(value: 0) return } - // Keep track of which message is handling the swipe (only should start when dragging from the message content) - let currentFrame = geometry.frame(in: .global) - if utils.messageCachingUtils.swipeToReplyId == nil, currentFrame.contains(messageListSwipe.startLocation) { + if utils.messageCachingUtils.swipeToReplyId == nil, geometry.frame(in: .global).contains(messageListSwipe.startLocation) { utils.messageCachingUtils.swipeToReplyId = message.id } guard utils.messageCachingUtils.swipeToReplyId == message.id else { return } - - // When dragging moves outside of the message row, stop handling swiping - guard messageListSwipe.currentLocation.y <= currentFrame.maxY, - messageListSwipe.currentLocation.y >= currentFrame.minY else { return } if messageListSwipe.horizontalOffset == 0 { setOffsetX(value: 0) } else { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 4780a1e7..22637b72 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -322,15 +322,10 @@ public struct MessageListView: View, KeyboardReadable { guard value.velocity == .zero || abs(value.velocity.width) > abs(value.velocity.height) else { return } guard abs(value.translation.width) > abs(value.translation.height) else { return } guard value.translation.width != messageListSwipe?.horizontalOffset else { return } - messageListSwipe = MessageListSwipe( - startLocation: value.startLocation, - currentLocation: value.location, - horizontalOffset: value.translation.width - ) + messageListSwipe = MessageListSwipe(startLocation: value.startLocation, horizontalOffset: value.translation.width) } .onEnded { _ in - guard let offset = messageListSwipe?.horizontalOffset, - offset != 0 else { return } + guard let offset = messageListSwipe?.horizontalOffset, offset != 0 else { return } utils.messageCachingUtils.swipeToReplyId = nil messageListSwipe = nil } @@ -683,7 +678,6 @@ private struct MessageListSwipeKey: EnvironmentKey { struct MessageListSwipe: Equatable { let startLocation: CGPoint - let currentLocation: CGPoint let horizontalOffset: CGFloat }