Fixing Scroll Indicators in Non-Opaque WKWebViews

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.

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):

: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:

(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. Searching in that file for indicatorStyle reveals this lovely method:

- (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 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.

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, "[email protected]:")
}

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 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 “[email protected]:” string is the Objective-C type encoding 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…


  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. ↩︎

Comments

Comments powered by ActivityPub. To respond to this post or to another comment, copy its URL into the search interface of your client for Mastodon, Pleroma, or other compatible software. Learn more.