Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions docs/ios-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,28 @@ click handlers with feedback enabled. Eight focused editor, file-browser and
haptic checks pass per edition; the normal-app project check above also used the
quick-tools Save button. Physical haptic sensation remains unverified.

Main and preview WebViews enable element fullscreen. The existing `orientation`
plugin API applies portrait/landscape requests only during foreground fullscreen,
releases its policy on exit, reload or unlock, and temporarily restores normal
rotation while inactive. iOS rejects Android Back-handler registration; release
remains safe. Simulator tests verify real fullscreen entry, rotation, restoration
and preview viewport scaling. The main WebView uses an autoresizing child inside
a constrained wrapper because WebKit reparents it and removes its constraints.
The main WebView implements element fullscreen using a manual
popover and edge-to-edge native layout, preserving iframe state and touch
coordinates without WebKit's browser fullscreen presentation. Fullscreen styles
apply to the owner and its shadow hosts; inline layout overrides are restored on
exit. Iframe documents and preview WebViews retain WebKit fullscreen. The existing `orientation` plugin API applies
portrait/landscape requests only during foreground fullscreen, releases its policy
on exit, reload or unlock, and temporarily restores normal rotation while inactive.
A completed downward swipe (at least 40 points) starting anywhere across the top
(within 44 points below the top safe-area inset)
reveals the native Back button in main-app fullscreen. It hides after three seconds,
on backgrounding or on exit; tapping restarts its hide timer. A completed left-edge
swipe (at least 60 points) delivers Back directly. `fullscreen.setBackHandler(callback | null)`
receives these actions through `fullscreenbackbutton`; without a handler they exit
fullscreen. A visible modal within the fullscreen owner gets the first opportunity
to handle Escape, regardless of focus; native dialogs receive a cancellable close
request that consumes Back. Back closes Astray's open pause menu and
resumes the game even after interacting with the menu or revealing the native button.
Registration clears on exit or reload, survives orientation unlock, and delivery
is disabled while inactive. Games such as Astray can open their pause menu while
remaining in landscape, then restore the editor through Exit. Simulator tests cover
ownership, callback failure, Back controls, touch coordinates, rotation and restoration.
Motion sensor readings require a physical device; simulated inputs are test fixtures.
On iPadOS 18.6 and 26.5 with multitasking enabled, UIKit rejects programmatic
orientation changes. The API rejects its promise and clears the requested policy;
fullscreen entry/exit and normal device rotation remain available. Keep iPad
Expand Down
4 changes: 4 additions & 0 deletions platforms/ios/ads/AMBBannerLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ final class AMBBannerLayout {
guard !layingOut, let controller = plugin?.viewController else { return }
layingOut = true
defer { layingOut = false }
if controller.isFullscreenLayout {
for banner in banners { banner.bannerView?.isHidden = true }
return
}
let root = controller.view!
let safe = root.safeAreaLayoutGuide.layoutFrame
let bottom = min(safe.maxY, root.keyboardLayoutGuide.layoutFrame.minY)
Expand Down
105 changes: 105 additions & 0 deletions platforms/ios/runner/FullscreenBackControls.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import UIKit

@MainActor
final class FullscreenBackControls: NSObject, UIGestureRecognizerDelegate {
private let button = UIButton(type: .system)
private let gesture = UIScreenEdgePanGestureRecognizer()
private let revealGesture = UIPanGestureRecognizer()
private let action: () -> Void
private var available = false
private var hideTask: Task<Void, Never>?

init(controller: UIViewController, action: @escaping () -> Void) {
self.action = action
super.init()
var configuration = UIButton.Configuration.gray()
configuration.image = UIImage(systemName: "chevron.left")
configuration.baseForegroundColor = .label
configuration.background.backgroundColor = .secondarySystemBackground
configuration.cornerStyle = .capsule
button.configuration = configuration
button.accessibilityLabel = "Back"
button.accessibilityIdentifier = "fullscreen-back"
button.isHidden = true
button.addTarget(self, action: #selector(back), for: .touchUpInside)
controller.view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 44),
button.heightAnchor.constraint(equalToConstant: 44),
button.leadingAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.leadingAnchor, constant: 8),
button.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8),
])
gesture.edges = .left
gesture.isEnabled = false
gesture.delegate = self
gesture.addTarget(self, action: #selector(swiped))
controller.view.addGestureRecognizer(gesture)
revealGesture.isEnabled = false
revealGesture.delegate = self
revealGesture.maximumNumberOfTouches = 1
revealGesture.addTarget(self, action: #selector(revealed))
controller.view.addGestureRecognizer(revealGesture)
}

func update(active: Bool, enabled: Bool, theme: String) {
available = active && enabled
if !available { hide() }
button.isEnabled = available
button.overrideUserInterfaceStyle = theme == "light" ? .light : .dark
gesture.isEnabled = available
revealGesture.isEnabled = available
}

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard gestureRecognizer === revealGesture, let view = revealGesture.view else { return true }
return touch.location(in: view).y <= view.safeAreaInsets.top + 44
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === revealGesture {
let velocity = revealGesture.velocity(in: revealGesture.view)
return velocity.y > abs(velocity.x)
}
let velocity = gesture.velocity(in: gesture.view)
return velocity.x > abs(velocity.y)
}

@objc private func back() {
show()
action()
}

@objc private func revealed() {
let translation = revealGesture.translation(in: revealGesture.view)
if revealGesture.state == .ended, translation.y >= 40, translation.y > abs(translation.x) {
show()
}
}

@objc private func swiped() {
let translation = gesture.translation(in: gesture.view)
if gesture.state == .ended, translation.x >= 60, translation.x > abs(translation.y) {
action()
}
}

private func show() {
guard available else { return }
hideTask?.cancel()
button.isHidden = false
hideTask = Task { [weak self] in
do { try await Task.sleep(for: .seconds(3)) } catch { return }
guard !Task.isCancelled else { return }
self?.hide()
}
}

private func hide() {
hideTask?.cancel()
hideTask = nil
button.isHidden = true
}

deinit { hideTask?.cancel() }
}
47 changes: 41 additions & 6 deletions platforms/ios/runner/WebFullscreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,58 @@ final class WebFullscreen: NSObject {
private var pending: Callback?
private var requestID = UUID()
private var foreground = true
private var controls: FullscreenBackControls!
private var hasBackHandler = false
var isFullscreen: Bool {
let state = controller?.webView.fullscreenState
return controller?.isFullscreenLayout == true || state == .inFullscreen || state == .enteringFullscreen
}
private(set) var requestedOrientation: UIInterfaceOrientationMask?

init(controller: WebViewController) {
self.controller = controller
super.init()
controls = FullscreenBackControls(controller: controller) { [weak self] in self?.back() }
observation = controller.webView.observe(\.fullscreenState, options: [.new]) { [weak self] webView, _ in
MainActor.assumeIsolated {
if webView.fullscreenState == .notInFullscreen || webView.fullscreenState == .exitingFullscreen { self?.reset() }
guard let self, self.controller?.isFullscreenLayout != true else { return }
if webView.fullscreenState == .notInFullscreen || webView.fullscreenState == .exitingFullscreen {
self.hasBackHandler = false
self.reset()
}
}
}
NotificationCenter.default.addObserver(self, selector: #selector(pause), name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(resume), name: UIApplication.didBecomeActiveNotification, object: nil)
}

func setActive(_ active: Bool, callback: Callback? = nil) {
guard !active || foreground else { callback?.error("Fullscreen requires the foreground app."); return }
if !active { hasBackHandler = false; reset() }
controller?.setFullscreenLayout(active)
callback?.success()
}

func setBackHandler(_ enabled: Bool, callback: Callback) {
guard !enabled || (foreground && isFullscreen) else {
callback.error("Back handler requires foreground fullscreen."); return
}
hasBackHandler = enabled
callback.success()
}

func updateControls() {
controls.update(active: controller?.isFullscreenLayout == true, enabled: foreground, theme: controller?.themeType ?? "dark")
}

func back() {
guard foreground, isFullscreen else { return }
let script = hasBackHandler
? "document.dispatchEvent(new Event('fullscreenbackbutton'));"
: "Promise.resolve(document.exitFullscreen()).catch(console.error);"
controller?.webView.evaluateJavaScript(script, completionHandler: nil)
}

func setOrientation(_ value: Any?, callback: Callback) {
guard let value, !(value is NSNull) else { reset(); callback.success(); return }
let mask: UIInterfaceOrientationMask
Expand All @@ -49,19 +87,16 @@ final class WebFullscreen: NSObject {
restorePolicy()
}

private var isFullscreen: Bool {
let state = controller?.webView.fullscreenState
return state == .inFullscreen || state == .enteringFullscreen
}

@objc private func pause() {
foreground = false
updateControls()
if pending != nil { reset() }
else { cancelPending(); restorePolicy() }
}

@objc private func resume() {
foreground = true
updateControls()
if let mask = requestedOrientation, isFullscreen, let scene { apply(mask, scene: scene) }
}

Expand Down
32 changes: 27 additions & 5 deletions platforms/ios/runner/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ final class WebViewController: UIViewController {
private(set) var fullscreen: WebFullscreen!
private var contentTop: NSLayoutConstraint!
private var contentBottom: NSLayoutConstraint!
private var normalLayout: [NSLayoutConstraint] = []
private var fullscreenLayout: [NSLayoutConstraint] = []
var isFullscreenLayout: Bool { fullscreenLayout.first?.isActive == true }
private var scrollObservation: NSKeyValueObservation?

override func viewDidLoad() {
Expand All @@ -23,8 +26,8 @@ final class WebViewController: UIViewController {
contentController.add(WeakScriptMessageHandler(bridge), name: "exec")
config.userContentController = contentController
config.preferences.javaScriptCanOpenWindowsAutomatically = true

config.preferences.isElementFullscreenEnabled = true

config.allowsInlineMediaPlayback = true
webView = AppWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
Expand All @@ -37,20 +40,26 @@ final class WebViewController: UIViewController {
webView.backgroundColor = view.backgroundColor
webView.isOpaque = false

// WebKit reparents the WebView for fullscreen, removing its constraints.
let content = UIView()
view.addSubview(content)
content.addSubview(webView)
content.translatesAutoresizingMaskIntoConstraints = false
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentTop = content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
contentBottom = content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor)
NSLayoutConstraint.activate([
normalLayout = [
contentTop,
content.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
content.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
contentBottom,
])
]
fullscreenLayout = [
content.topAnchor.constraint(equalTo: view.topAnchor),
content.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]
NSLayoutConstraint.activate(normalLayout)

// The keyboard layout guide resizes the editor; WebKit scrolling would shift it twice.
scrollObservation = webView.scrollView.observe(\.contentOffset, options: [.new]) { scrollView, _ in
Expand Down Expand Up @@ -80,6 +89,16 @@ final class WebViewController: UIViewController {
view.layoutIfNeeded()
}

func setFullscreenLayout(_ active: Bool) {
NSLayoutConstraint.deactivate(active ? normalLayout : fullscreenLayout)
NSLayoutConstraint.activate(active ? fullscreenLayout : normalLayout)
setThemeType(themeType)
setNeedsUpdateOfHomeIndicatorAutoHidden()
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
view.layoutIfNeeded()
bridge.adsService?.banners.layout()
}

@objc func appMovedToBackground() {
let script = "document.dispatchEvent(new CustomEvent('pause'));"
webView.evaluateJavaScript(script, completionHandler: nil)
Expand Down Expand Up @@ -123,7 +142,9 @@ final class WebViewController: UIViewController {
}

var statusBarHidden = false
override var prefersStatusBarHidden: Bool { statusBarHidden }
override var prefersStatusBarHidden: Bool { statusBarHidden || isFullscreenLayout }
override var prefersHomeIndicatorAutoHidden: Bool { isFullscreenLayout }
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { isFullscreenLayout ? .all : [] }

var systemConfiguration: [String: Any] {
let hardwareKeyboard = GCKeyboard.coalesced != nil
Expand All @@ -137,6 +158,7 @@ final class WebViewController: UIViewController {

func setThemeType(_ type: String) {
themeType = type
fullscreen?.updateControls()
setNeedsStatusBarAppearanceUpdate()
var vc: UIViewController? = parent
while let current = vc {
Expand Down
15 changes: 12 additions & 3 deletions platforms/ios/runner/lib/SystemService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class SystemService: BaseService {
share.reset()
DispatchQueue.main.async { [weak self] in
IncomingLinks.shared.reset()
self?.viewController?.fullscreen.reset()
self?.viewController?.fullscreen.setActive(false)
(self?.webView as? AppWebView)?.nativeContextMenuDisabled = false
}
}
Expand Down Expand Up @@ -76,14 +76,23 @@ final class SystemService: BaseService {
}
case "get-intent": DispatchQueue.main.async { IncomingLinks.shared.current(callback) }
case "set-intent-handler": DispatchQueue.main.async { IncomingLinks.shared.listen(callback) }
case "set-fullscreen":
DispatchQueue.main.async { [weak self] in
guard let fullscreen = self?.viewController?.fullscreen,
let active = args[safe: 0] as? Bool else { callback.error("Invalid fullscreen request"); return }
fullscreen.setActive(active, callback: callback)
}
case "set-fullscreen-orientation":
DispatchQueue.main.async { [weak self] in
guard let fullscreen = self?.viewController?.fullscreen else { callback.error("View unavailable"); return }
fullscreen.setOrientation(args[safe: 0], callback: callback)
}
case "set-fullscreen-back-handler":
if args[safe: 0] as? Bool == true { callback.error("Android Back handling is unavailable on iOS") }
else { callback.success() }
DispatchQueue.main.async { [weak self] in
guard let fullscreen = self?.viewController?.fullscreen,
let enabled = args[safe: 0] as? Bool else { callback.error("Invalid Back handler request"); return }
fullscreen.setBackHandler(enabled, callback: callback)
}
case "set-native-context-menu-disabled":
DispatchQueue.main.async { [weak self] in
(self?.webView as? AppWebView)?.nativeContextMenuDisabled = (args[safe: 0] as? String == "true")
Expand Down
Loading
Loading