Files
DIYgod af61ab27d4 fix(mobile): keep header buttons clear of the iPadOS window controls
In a window that does not fill the screen, iPadOS 26 draws its window controls
over the header's first button, so the back button, the home avatar and the
start of the Discover search could not be tapped. iOS now reports the room the
controls take, and screen headers and the image viewer leave it free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:41:44 +08:00

229 lines
7.8 KiB
Swift

//
// HelperModule.swift
// Pods
//
// Created by Innei on 2025/2/7.
//
import ExpoModulesCore
import UIKit
let onWindowControlsInsetsChange = "onWindowControlsInsetsChange"
public class HelperModule: Module {
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("Helper")
Events(onWindowControlsInsetsChange)
// Room the iPadOS window controls take past the safe area, see WindowControlsInsetsObserver
AsyncFunction("getWindowControlsInsets") { () -> [String: Double] in
WindowControlsInsetsObserver.shared.currentInsets().dictionary
}.runOnQueue(.main)
OnStartObserving(onWindowControlsInsetsChange) {
DispatchQueue.main.async { [weak self] in
let observer = WindowControlsInsetsObserver.shared
observer.onChange = { insets in
self?.sendEvent(onWindowControlsInsetsChange, insets.dictionary)
}
observer.installProbeIfNeeded()
}
}
OnStopObserving(onWindowControlsInsetsChange) {
DispatchQueue.main.async {
WindowControlsInsetsObserver.shared.onChange = nil
}
}
AsyncFunction("openLink") { (urlString: String, promise: Promise) in
guard let url = URL(string: urlString) else {
// Nothing opens, which settles the call as a closed browser would
promise.resolve(["type": "dismiss"])
return
}
DispatchQueue.main.async {
guard let rootVC = Utils.getRootVC() else { return }
let onDismiss = {
promise.resolve(["type": "dismiss"])
}
WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)
}
}
AsyncFunction("dismissPresentedBrowser") { (promise: Promise) in
DispatchQueue.main.async {
WebViewManager.dismissPresentedBrowser { dismissed in
promise.resolve(dismissed)
}
}
}
Function("scrollToTop") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
scrollView.scrollToTopIfPossible(animated: true)
}
}
}
AsyncFunction("isScrollToEnd") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.bounds.size.height
let contentOffsetY = scrollView.contentOffset.y
let bottomOffset = contentHeight - scrollViewHeight
promise.resolve(contentOffsetY >= bottomOffset - 1.0)
}
}
}
Function("saveImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)
Toast.show(options: .init(type: .success, title: "Saved to photos"))
}
}
}
Function("shareImageByHandle") { (reactTag: Int, url: String?) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
let activityViewController = UIActivityViewController(
activityItems: [
image.asActivityItemSource(
url: try? URL(string: url ?? "")
)
], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
Utils.getRootVC()?.present(activityViewController, animated: true)
}
}
}
AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
promise.reject(
NSError(
domain: "HelperModule", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Image view not found"]))
return
}
let base64 = self?.getBase64FromImageView(imageView: imageView)
promise.resolve(["base64": base64])
}
}
}
Function("copyImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
if let sourceView = self?.appContext?.findView(withTag: reactTag, ofType: UIView.self) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
guard let imageData = image.pngData() else { return }
UIPasteboard.general.setData(imageData, forPasteboardType: "public.png")
Toast.show(options: .init(type: .success, title: "Image copied to clipboard"))
}
}
}
}
func getBase64FromImageView(imageView: UIImageView) -> String? {
guard let image = imageView.image else { return nil }
guard let imageData = image.pngData() else { return nil }
let base64String = imageData.base64EncodedString(options: .lineLength64Characters)
return base64String
}
@objc func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?
) {
if let error = error {
Toast.show(options: .init(type: .error, title: "Save image failed"))
} else {
Toast.show(options: .init(type: .success, title: "Saved to photos"))
}
}
private func findUIScrollView(view: UIView?) -> UIScrollView? {
return findUIViewOfType(view: view)
}
private func findUIImageView(view: UIView?) -> UIImageView? {
return findUIViewOfType(view: view)
}
private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {
guard let view = view else {
return nil
}
if let view = view as? T {
return view
}
let subviews = view.subviews
for subview in subviews {
if let targetView = findUIViewOfType(view: subview) as T? {
return targetView
}
}
return nil
}
}
extension UIScrollView {
func scrollToTopIfPossible(animated: Bool) {
let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:"
if let decodedData = Data(base64Encoded: encodedSelector),
let decodedString = String(data: decodedData, encoding: .utf8)
{
let selector = NSSelectorFromString(decodedString)
if responds(to: selector) {
perform(selector, with: animated)
} else {
print("UIScrollView does not respond to decoded method")
setContentOffset(.zero, animated: animated)
}
} else {
setContentOffset(.zero, animated: animated)
}
}
}