Video Player Integration
Native Apps Integration
iOS
iOS 26+ Rotation Workaround

iOS 26+ CSS Rotation Workaround for WKWebView Fullscreen

The Problem

Starting with iOS 26, Apple introduced a bug where calling requestGeometryUpdate (programmatic orientation change) breaks WKWebView's internal gesture recognizers. After a programmatic rotation, touch events stop being delivered to web content — taps, swipes, and all interactions inside the WebView become unresponsive.

This is a confirmed Apple bug (similar to this Flutter issue (opens in a new tab)) affecting any native app that embeds a WKWebView and programmatically rotates the interface.

Root Cause

WKWebView (WebKit) uses its own internal UIGestureRecognizer instances to handle touch events. When requestGeometryUpdate triggers an orientation change, these recognizers enter a broken state and never recover — even after rotating back.


The Solution: CSS Rotation

Instead of rotating the entire app interface via your app's native implementation, keep the app in portrait and use a CSS transform: rotate() injected into the WebView to simulate landscape layout. This avoids calling requestGeometryUpdate entirely, so WebKit's gesture recognizers remain intact.

How It Works

  1. The HTML content inside the WebView is rotated 90° via CSS
  2. The width and height are swapped using viewport units (100vh / 100vw)
  3. The content is repositioned so it fills the portrait screen as if it were landscape
  4. Touch events continue to work because no UIKit orientation change occurred

Implementation Guide

Step 1: Detect iOS 26+

Add a version check so you only apply the CSS workaround on affected versions. Earlier iOS versions can continue using native requestGeometryUpdate.

private var isIOS26Plus: Bool {
  if let majorVersion = Int(
    UIDevice.current.systemVersion
      .components(separatedBy: ".").first ?? "0"
  ) {
    return majorVersion >= 26
  }
  return false
}

Step 2: Add CSS Rotation Helpers

These two methods inject and remove a <style> tag that rotates the <html> element.

/// Tracks whether CSS rotation is currently active
@State private var isCSSRotated: Bool = false
 
/// Injects a CSS transform to rotate web content 90° and fill the viewport.
/// Rotation direction is based on the device's physical orientation.
private func applyCSSRotation() {
  let orientation = UIDevice.current.orientation
  // Match rotation direction to device tilt
  let degrees = orientation == .landscapeRight ? -90 : 90
  let position = degrees == 90
    ? "top: 0; left: 100vw;"    // clockwise: anchor top-left, shift right
    : "top: 100vh; left: 0;"    // counterclockwise: anchor top-left, shift down
 
  let script = """
  (function() {
    var existing = document.getElementById('gs-css-rotation');
    if (existing) existing.remove();
    var style = document.createElement('style');
    style.id = 'gs-css-rotation';
    style.textContent = 'html { \
      position: fixed !important; \
      transform-origin: top left !important; \
      transform: rotate(\(degrees)deg) !important; \
      width: 100vh !important; \
      height: 100vw !important; \
      \(position) \
      overflow: hidden !important; \
    }';
    document.head.appendChild(style);
  })();
  """
  webView.evaluateJavaScript(script)
  isCSSRotated = true
}
 
/// Removes the injected CSS rotation, restoring normal layout.
private func removeCSSRotation() {
  let script = """
  (function() {
    var style = document.getElementById('gs-css-rotation');
    if (style) style.remove();
  })();
  """
  webView.evaluateJavaScript(script)
  isCSSRotated = false
}

Step 3: Modify Your Fullscreen Toggle

When the WebView requests fullscreen (via a JavaScript bridge message):

func messageHandler(type: String) {
  if (type == "toggleFullscreen") {
    isPresented.toggle()
 
    if isIOS26Plus {
      // iOS 26+: Use CSS rotation to avoid the touch event bug
      if isPresented {
        let isCurrentlyPortrait =
          UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
        if isCurrentlyPortrait {
          // Delay slightly to let SwiftUI layout settle at fullscreen size
          DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            self.applyCSSRotation()
          }
        }
        // If already in physical landscape, no CSS rotation needed
      } else {
        // Exiting fullscreen — remove CSS rotation
        removeCSSRotation()
      }
    } else {
      // iOS < 26: Native programmatic rotation works fine
      changeOrientation(to: isPresented ? .landscape : .portrait)
    }
  } else if (type == "init") {
    isVideoPlayerReady = true
    onToggleFullscreen()
  }
}

Step 4: Handle Physical Device Rotation

When the user physically rotates their device, you need to coordinate with the CSS rotation state:

func onToggleFullscreen() {
  if(selectedTab == 0){
    // This is needed to avoid unknown orientation value
    let isPortrait = UIDevice.current.orientation.rawValue == 0 || UIDevice.current.orientation.isFlat
    ? UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
    : UIDevice.current.orientation == .portrait || UIDevice.current.orientation.isFlat
 
    orientationSource = .accelerometer
 
    // On iOS 26+: if device physically rotated to landscape while CSS rotation is active,
    // remove CSS rotation — native landscape takes over seamlessly
    if !isPortrait && isPresented && isCSSRotated {
      removeCSSRotation()
      return
    }
 
    // Normal fullscreen toggle based on physical rotation
    if (!isPortrait && !isPresented) || (isPortrait && isPresented) {
      hideKeyboard()
      if isCSSRotated {
        removeCSSRotation()
      }
      isPresented.toggle()
      let script = """
      document.querySelector(".video-container")?.classList.toggle("full-screen")
    """
      webViewCoordinator.webView.evaluateJavaScript(script)
    }
  }
}

Register this handler for orientation change notifications:

private let rotationPublisher = NotificationCenter.default
  .publisher(for: UIDevice.orientationDidChangeNotification)
 
// In your view body:
.onReceive(rotationPublisher) { _ in
  onToggleFullscreen()
}

CSS Rotation Explained

The CSS transform works by re-laying out the HTML at landscape dimensions while visually rotating it to fit the portrait viewport:

Portrait viewport (390 × 844)
┌─────────────────────┐
│                     │
│   HTML rendered at  │
│   844 × 390         │
│   (landscape dims)  │
│                     │
│   Then rotated 90°  │
│   to fill portrait  │
│   viewport          │
│                     │
└─────────────────────┘

For clockwise rotation (90°)

Simulates landscapeLeft — content top points right.

html {
  position: fixed;
  transform-origin: top left;
  transform: rotate(90deg);
  width: 100vh;   /* viewport height becomes content width */
  height: 100vw;  /* viewport width becomes content height */
  top: 0;
  left: 100vw;    /* shift right so rotated content lands in view */
  overflow: hidden;
}

For counterclockwise rotation (-90°)

Simulates landscapeRight — content top points left.

html {
  position: fixed;
  transform-origin: top left;
  transform: rotate(-90deg);
  width: 100vh;
  height: 100vw;
  top: 100vh;     /* shift down so rotated content lands in view */
  left: 0;
  overflow: hidden;
}

Behavior Summary

ScenarioiOS < 26iOS 26+
Fullscreen button (portrait)requestGeometryUpdate → native landscapeCSS rotation → simulated landscape
Fullscreen button (landscape)requestGeometryUpdate → portraitToggle isPresented only
Physical rotation to landscapeNative rotation, no API callNative rotation + auto-remove CSS if active
Physical rotation to portraitExit fullscreen naturallyExit fullscreen + clean up CSS
Touch eventsAlways workAlways work (no requestGeometryUpdate called)

References