-
Notifications
You must be signed in to change notification settings - Fork 51
fix(#278): restore swipe-to-exit gesture in posts without breaking image galleries #306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/v5
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import WebKit | ||
|
|
||
| /// Receives gallery open/close notifications posted by | ||
| /// `MMWebViewUserScripts.galleryStateObserver`. | ||
| @MainActor | ||
| final class GalleryStateMessageHandler: NSObject, WKScriptMessageHandler { | ||
| static let handlerName = "mmGalleryState" | ||
|
|
||
| var onGalleryStateChange: ((Bool) -> Void)? | ||
|
|
||
| func userContentController( | ||
| _ userContentController: WKUserContentController, | ||
| didReceive message: WKScriptMessage | ||
| ) { | ||
| guard let isOpen = message.body as? Bool else { return } | ||
| onGalleryStateChange?(isOpen) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import SwiftUI | ||
| import UIKit | ||
|
|
||
| public extension View { | ||
| /// Restores the interactive pop gesture (swipe from the leading edge to go back) | ||
| /// even when the system back button is hidden, and disables it on demand — | ||
| /// e.g. while an in-page image gallery is open and owns horizontal swipes. | ||
| func interactivePopGesture(enabled: Bool) -> some View { | ||
| background(InteractivePopGestureBridge(enabled: enabled)) | ||
| } | ||
| } | ||
|
|
||
| private struct InteractivePopGestureBridge: UIViewControllerRepresentable { | ||
| let enabled: Bool | ||
|
|
||
| func makeUIViewController(context: Context) -> BridgeViewController { | ||
| BridgeViewController() | ||
| } | ||
|
|
||
| func updateUIViewController(_ controller: BridgeViewController, context: Context) { | ||
| controller.gestureEnabled = enabled | ||
| } | ||
| } | ||
|
|
||
| private final class BridgeViewController: UIViewController, UIGestureRecognizerDelegate { | ||
| var gestureEnabled = true | ||
|
|
||
| private weak var popGesture: UIGestureRecognizer? | ||
| private weak var originalDelegate: UIGestureRecognizerDelegate? | ||
|
|
||
| override func viewDidAppear(_ animated: Bool) { | ||
| super.viewDidAppear(animated) | ||
| guard popGesture == nil, | ||
| let gesture = navigationController?.interactivePopGestureRecognizer else { return } | ||
| popGesture = gesture | ||
| originalDelegate = gesture.delegate | ||
| gesture.delegate = self | ||
| } | ||
|
|
||
| override func viewWillDisappear(_ animated: Bool) { | ||
| super.viewWillDisappear(animated) | ||
| restoreDelegate() | ||
| } | ||
|
|
||
| isolated deinit { | ||
| restoreDelegate() | ||
| } | ||
|
|
||
| private func restoreDelegate() { | ||
| if let popGesture, popGesture.delegate === self { | ||
| popGesture.delegate = originalDelegate | ||
| } | ||
| popGesture = nil | ||
| originalDelegate = nil | ||
| } | ||
|
|
||
| func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { | ||
| guard let navigationController else { return false } | ||
| return gestureEnabled | ||
| && navigationController.viewControllers.count > 1 | ||
| && navigationController.transitionCoordinator == nil | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ public struct MMWebView: View { | |
| @State private var internalLinkURL: URL? | ||
| @State private var page: WebPage? | ||
| @State private var navigationDecider = MMNavigationDecider() | ||
| @State private var galleryStateHandler = GalleryStateMessageHandler() | ||
| @State private var isGalleryOpen = false | ||
| @State private var reloadID = UUID() | ||
|
|
||
| private let url: String? | ||
|
|
@@ -41,6 +43,7 @@ public struct MMWebView: View { | |
| page: $page, | ||
| reloadTrigger: reloadID | ||
| ) | ||
| .interactivePopGesture(enabled: !isGalleryOpen) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug:
The cleanest fix is in the script: initialize var lastState = null;
function check() {
var open = !!document.querySelector(selectors);
if (open !== lastState) {
lastState = open;
window.webkit.messageHandlers.mmGalleryState.postMessage(open);
}
}
var observer = new MutationObserver(check);
observer.observe(document.documentElement, { ... });
check();Optionally also reset on the native side next to the existing reload triggers: .onChange(of: colorScheme) {
isGalleryOpen = false
page?.reload()
}
.onChange(of: removeAds) {
if let cacheKey {
WebPageCache.shared.removePage(for: cacheKey)
}
isGalleryOpen = false
page = nil
reloadID = UUID()
} |
||
| .navigationDestination(item: $internalLinkURL) { url in | ||
| MMWebView(url: url.absoluteString, dismissAction: dismissAction) | ||
| .toolbar { | ||
|
|
@@ -62,12 +65,14 @@ public struct MMWebView: View { | |
| } | ||
| } | ||
| .onChange(of: colorScheme) { | ||
| isGalleryOpen = false | ||
| page?.reload() | ||
| } | ||
| .onChange(of: removeAds) { | ||
| if let cacheKey { | ||
| WebPageCache.shared.removePage(for: cacheKey) | ||
| } | ||
| isGalleryOpen = false | ||
| page = nil | ||
| reloadID = UUID() | ||
| } | ||
|
|
@@ -102,6 +107,7 @@ private extension MMWebView { | |
|
|
||
| navigationDecider.onOpenComments = { [self] slug in commentsURL = slug } | ||
| navigationDecider.onOpenInternalLink = { [self] url in internalLinkURL = url } | ||
| galleryStateHandler.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen } | ||
|
|
||
| let configuration = makeConfiguration() | ||
| let page: WebPage | ||
|
|
@@ -110,8 +116,11 @@ private extension MMWebView { | |
| page = WebPageCache.shared.page( | ||
| for: cacheKey, | ||
| configurationProvider: { configuration }, | ||
| navigationDecider: navigationDecider | ||
| navigationDecider: navigationDecider, | ||
| galleryStateHandler: galleryStateHandler | ||
| ) | ||
| WebPageCache.shared.galleryHandler(for: cacheKey)? | ||
| .onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen } | ||
| } else { | ||
| page = WebPage( | ||
| configuration: configuration, | ||
|
|
@@ -138,6 +147,8 @@ private extension MMWebView { | |
| contentController.addUserScript(MMWebViewUserScripts.disableGallery) | ||
| contentController.addUserScript(MMWebViewUserScripts.disableNewGallery) | ||
| contentController.addUserScript(MMWebViewUserScripts.removeBackToBlog) | ||
| contentController.addUserScript(MMWebViewUserScripts.galleryStateObserver) | ||
| contentController.add(galleryStateHandler, name: GalleryStateMessageHandler.handlerName) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: on a
Today only the Live/Instagram root tabs use Suggested fix — cache the handler alongside the page and rebind its closure on every hit: // WebPageCache
private var galleryHandlers: [String: GalleryStateMessageHandler] = [:]
func page(
for key: String,
configurationProvider: () -> WebPage.Configuration,
navigationDecider: some WebPage.NavigationDeciding,
galleryStateHandler: GalleryStateMessageHandler
) -> WebPage {
if let existing = cache[key] { return existing }
let page = WebPage(configuration: configurationProvider(),
navigationDecider: navigationDecider)
cache[key] = page
galleryHandlers[key] = galleryStateHandler
return page
}
func galleryHandler(for key: String) -> GalleryStateMessageHandler? {
galleryHandlers[key]
}
func removePage(for key: String) {
cache.removeValue(forKey: key)
galleryHandlers.removeValue(forKey: key)
}// MMWebView.makePage()
if let cacheKey {
page = WebPageCache.shared.page(
for: cacheKey,
configurationProvider: { configuration },
navigationDecider: navigationDecider,
galleryStateHandler: galleryStateHandler
)
// Rebind the registered handler to this view instance on cache hits.
WebPageCache.shared.galleryHandler(for: cacheKey)?
.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen }
} |
||
|
|
||
| return configuration | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardening: the system delegate is never restored if this controller deallocates without
viewWillDisappear.UIGestureRecognizer.delegateis weak, so ifBridgeViewControllerdies on a teardown path that skips appearance callbacks, the pop gesture is left with anildelegate — completely ungated. An edge swipe on a root view controller can then begin a pop with nothing to pop, which is the classic frozen-navigation bug.Since the project is Swift 6.2,
isolated deinit(SE-0371) makes this a clean fix — extract the restore logic and call it from both places: