shadowfacts.net/site/posts/2022-01-14-wkwebview-scroll...

99 lines
7.4 KiB
Markdown

```
metadata.title = "Fixing Scroll Indicators in Non-Opaque WKWebViews"
metadata.tags = ["swift"]
metadata.date = "2022-01-14 19:08:42 -0400"
metadata.shortDesc = "3 out of 5 stars, would swizzle again"
metadata.slug = "wkwebview-scroll-indicators"
```
Here's a stupid bug I ran into recently: if you've got a WKWebView in an iOS app and it shows something that's not a normal webpage[^1], the scroll indicator appearance switching doesn't work. What I mean by that is, while the indicators appear correctly (with a dark color) when the system is in light mode, they do not take on a light color when dark mode is enabled. This renders the scroll indicators invisible against dark backgrounds, which can be annoying if you're using the web view to display potentially lengthy content.
[^1]: I say this because the only way I've tested it is by generating some HTML and giving it to `loadHTMLString(_:baseURL:)`. It's entirely possible this is not a factor.
<!-- excerpt-end -->
Let's say you've got a web view with some content that you want to match the system color scheme. The simplest way to do that is to set the web view's background color to `.systemBackground` and add the following to the page's stylesheet (to make the default text color change with the theme):
```css
:root {
color-scheme: light dark;
}
```
If you haven't changed the page's `background-color` in CSS, this will correctly make both the background and text colors follow the system theme. Unfortunately, it has no effect on the scroll indicator. Fixing that, it turns out, is rather involved.
The obvious thing to do would be to just set the `indicatorStyle` to `.default` on the web view's internal scroll view, which should theoretically make the indicators automatically adjust to the color of the content inside the scroll view. Try this, however, and you'll find that it does not work.
Regardless of where you set the property (in `viewDidLoad`, `viewWillAppear`, etc.) it appears to never be respected. Launching the app and checking `webView.scrollView.indicatorStyle` when paused in the view debugger reveals why: it's been changed to `.black`:
```txt
(lldb) po [(UIScrollView*)0x7fcc9e817000 indicatorStyle]
UIScrollViewIndicatorStyleBlack
```
Because WebKit—including, surprisingly, some the iOS platform-specific parts of it—is open source, we can go poking through it and try to figure out why this is happening. Looking for files with names like `WKWebView` in the WebKit source code reveals the promising-sounding [`WKWebViewIOS.mm`](https://github.com/WebKit/WebKit/blob/c2eb26fec22295b1bbd1d790f8492072b2c05447/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L540-L556). Searching in that file for `indicatorStyle` reveals this lovely method:
```objective-c++
- (void)_updateScrollViewBackground
{
auto newScrollViewBackgroundColor = scrollViewBackgroundColor(self, AllowPageBackgroundColorOverride::Yes);
if (_scrollViewBackgroundColor != newScrollViewBackgroundColor) {
_scrollViewBackgroundColor = newScrollViewBackgroundColor;
auto uiBackgroundColor = adoptNS([[UIColor alloc] initWithCGColor:cachedCGColor(newScrollViewBackgroundColor)]);
[_scrollView setBackgroundColor:uiBackgroundColor.get()];
}
// Update the indicator style based on the lightness/darkness of the background color.
auto newPageBackgroundColor = scrollViewBackgroundColor(self, AllowPageBackgroundColorOverride::No);
if (newPageBackgroundColor.lightness() <= .5f && newPageBackgroundColor.isVisible())
[_scrollView setIndicatorStyle:UIScrollViewIndicatorStyleWhite];
else
[_scrollView setIndicatorStyle:UIScrollViewIndicatorStyleBlack];
}
```
"Update the indicator style based on the lightness/darkness of the background color." Ah. That explains it. It's examining the background color and using its lightness to explicitly set the scroll view's indicator style to either white or black.
And why's it overriding the `.default` that we set? Well, that method is called from (among other places) `_didCommitLayerTree`, which, as I understand it, is called whenever the remote WebKit process sends the in-process web view a new layer tree to display—so basically continuously.
Knowing that WebKit is using the page's background color to set the indicator style, the answer seems simple, right? Just set the page's background color in CSS to match the current color scheme. Wrong: setting `background-color: black;` on the `body` does not alter the scroll indicator color.
And why is that? It's because `scrollViewBackgroundColor` ignores all other factors and returns a transparent black color if the web view is non-opaque. And since the `isVisible` method returns false for fully transparent colors, the scroll indicator style is being set to black.
Now, this is where the [minor iOS crimes](https://social.shadowfacts.net/notice/ABya4BV7zlJZrNKMkK) come in. If you can't make the framework do what you want, change (read: swizzle) the framework.
So, in the `didFinishLaunching` app delegate callback, I swizzle the `_updateScrollViewBackground` method of `WKWebView`. My swizzled version calls the original implementation and then sets the scroll indicator mode back to `.default`, superseding whatever WebKit changed it to. And this finally makes the scroll indicator visible in dark mode.
```swift
private func swizzleWKWebView() {
let selector = Selector(("_updateScrollViewBackground"))
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: WKWebView) in
if let originalIMP = originalIMP {
let original = unsafeBitCast(originalIMP, to: (@convention(c) (WKWebView, Selector) -> Void).self)
original(self, selector)
} else {
os_log(.error, "Missing originalIMP for -[WKWebView _updateScrollViewBackground], did WebKit change?")
}
self.scrollView.indicatorStyle = .default
} as (@convention(block) (WKWebView) -> Void))
originalIMP = class_replaceMethod(WKWebView.self, selector, imp, "v@:")
}
```
A couple things to note about this code:
`originalIMP`, is optional because `class_replaceMethod` returns nil if the method does not already exist, though it still adds our new implementation. If this happens, I log a message because it probably means that WebKit has changed and hopefully this hack is no longer necessary (or that it may need updating).
The `unsafeBitCast` is necessary because an [`IMP`](https://developer.apple.com/documentation/objectivec/objective-c_runtime/imp) is in fact a C function pointer but it's imported into Swift as an OpaquePointer.
The Swift closure is cast to an Objective-C block because, although `imp_implementationWithBlock` is imported into Swift as taking a parameter of type `Any`, what it really needs, as the name implies, is a block.
The "v@:" string is the Objective-C [type encoding](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100) of the method's signature (it returns void, takes the self instance, and the selector for the method being called).
And with that annoying workaround, the scroll indicator is finally visible in dark mode (and updates correctly when switching color schemes). Given that `WKWebView` gives some consideration to the system color scheme, I'm inclined to think this is a bug, so hopefully it'll be fixed eventually. Alternatively, as I noted, that part of WebKit is in fact open source, so an intrepid developer could fix it themself...