import UIKit import WebKit final class NativeSdkHostViewController: UIViewController { private let headerView = UIView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let statusLabel = UILabel() private let backButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system) private let webView = WKWebView(frame: .zero) private var webViewBottomConstraint: NSLayoutConstraint? private var nativeApp: UnsafeMutableRawPointer? private var keyboardBottomInset: CGFloat = 0 private var widgetAccessibilityElements: [UIAccessibilityElement] = [] private struct WidgetSemantics { let id: UInt64 let parentId: UInt64 let role: Int32 let flags: UInt32 let actions: UInt32 let bounds: CGRect let value: Float? let label: String let text: String let textSelectionStart: Int let textSelectionEnd: Int let textCompositionStart: Int let textCompositionEnd: Int let gridRowIndex: Int let gridColumnIndex: Int let gridRowCount: Int let gridColumnCount: Int let listItemIndex: Int let listItemCount: Int let scrollOffset: Float let scrollViewportExtent: Float let scrollContentExtent: Float let hasScroll: Bool } private struct WidgetTextGeometry { let id: UInt64 let caretBounds: CGRect? let selectionBounds: CGRect? let selectionRectCount: Int let compositionBounds: CGRect? let compositionRectCount: Int } private final class WidgetAccessibilityElement: UIAccessibilityElement { private weak var owner: NativeSdkHostViewController? private let node: WidgetSemantics init(accessibilityContainer container: Any, owner: NativeSdkHostViewController, node: WidgetSemantics) { self.owner = owner self.node = node super.init(accessibilityContainer: container) } override func accessibilityActivate() -> Bool { owner?.activateWidgetAccessibilityNode(node) ?? false } override func accessibilityIncrement() { _ = owner?.incrementWidgetAccessibilityNode(node) } override func accessibilityDecrement() { _ = owner?.decrementWidgetAccessibilityNode(node) } override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool { switch direction { case .down, .right: return owner?.incrementWidgetAccessibilityNode(node) ?? false case .up, .left: return owner?.decrementWidgetAccessibilityNode(node) ?? false default: return false } } } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground configureHeader() headerView.translatesAutoresizingMaskIntoConstraints = false webView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(headerView) view.addSubview(webView) let webViewBottomConstraint = webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) self.webViewBottomConstraint = webViewBottomConstraint NSLayoutConstraint.activate([ headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), headerView.heightAnchor.constraint(equalToConstant: 104), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: headerView.bottomAnchor), webViewBottomConstraint, ]) NotificationCenter.default.addObserver(self, selector: #selector(keyboardFrameWillChange), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) nativeApp = native_sdk_app_create() if let nativeApp { native_sdk_app_start(nativeApp) refreshWidgetAccessibility() } webView.loadHTMLString(Self.html, baseURL: nil) } func activateNativeApp() { guard let nativeApp else { return } native_sdk_app_activate(nativeApp) } func deactivateNativeApp() { guard let nativeApp else { return } native_sdk_app_deactivate(nativeApp) } private func configureHeader() { headerView.backgroundColor = .secondarySystemBackground titleLabel.text = "Mobile Shell" titleLabel.font = .preferredFont(forTextStyle: .title2) titleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.text = "Native header with WebView workspace" subtitleLabel.font = .preferredFont(forTextStyle: .subheadline) subtitleLabel.textColor = .secondaryLabel subtitleLabel.adjustsFontForContentSizeCategory = true statusLabel.text = "System WebView" statusLabel.font = .preferredFont(forTextStyle: .caption1) statusLabel.textColor = .secondaryLabel statusLabel.backgroundColor = .tertiarySystemFill statusLabel.layer.cornerRadius = 11 statusLabel.layer.masksToBounds = true statusLabel.textAlignment = .center backButton.setTitle("Back", for: .normal) backButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) backButton.addTarget(self, action: #selector(sendBackCommand), for: .touchUpInside) refreshButton.setTitle("Refresh", for: .normal) refreshButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) refreshButton.addTarget(self, action: #selector(sendRefreshCommand), for: .touchUpInside) [titleLabel, subtitleLabel, statusLabel, backButton, refreshButton].forEach { $0.translatesAutoresizingMaskIntoConstraints = false headerView.addSubview($0) } NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20), titleLabel.topAnchor.constraint(equalTo: headerView.topAnchor, constant: 18), statusLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -20), statusLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), statusLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 112), statusLabel.heightAnchor.constraint(equalToConstant: 24), refreshButton.trailingAnchor.constraint(equalTo: statusLabel.trailingAnchor), refreshButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 8), backButton.trailingAnchor.constraint(equalTo: refreshButton.leadingAnchor, constant: -12), backButton.centerYAnchor.constraint(equalTo: refreshButton.centerYAnchor), subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: backButton.leadingAnchor, constant: -16), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), ]) } @objc private func sendBackCommand() { dispatchNativeCommand("mobile.back") } @objc private func sendRefreshCommand() { dispatchNativeCommand("mobile.refresh") } private func dispatchNativeCommand(_ command: String) { guard let nativeApp else { return } command.withCString { pointer in native_sdk_app_command(nativeApp, pointer, UInt(command.utf8.count)) } let count = native_sdk_app_last_command_count(nativeApp) let name = String(cString: native_sdk_app_last_command_name(nativeApp)) statusLabel.text = "\(name) #\(count)" native_sdk_app_frame(nativeApp) refreshWidgetAccessibility() } @objc private func keyboardFrameWillChange(_ notification: Notification) { guard let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } let keyboardFrame = view.convert(frameValue.cgRectValue, from: nil) let overlap = max(0, view.bounds.maxY - keyboardFrame.minY) keyboardBottomInset = overlap webViewBottomConstraint?.constant = -overlap animateKeyboardLayout(notification) sendViewportUpdate() } @objc private func keyboardWillHide(_ notification: Notification) { keyboardBottomInset = 0 webViewBottomConstraint?.constant = 0 animateKeyboardLayout(notification) sendViewportUpdate() } private func animateKeyboardLayout(_ notification: Notification) { let userInfo = notification.userInfo ?? [:] let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 let curve = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 0 let options = UIView.AnimationOptions(rawValue: curve << 16) UIView.animate(withDuration: duration, delay: 0, options: options) { self.view.layoutIfNeeded() } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() sendViewportUpdate() } private func sendViewportUpdate() { guard let nativeApp else { return } let scale = Float(view.window?.screen.scale ?? UIScreen.main.scale) let safe = view.safeAreaInsets native_sdk_app_viewport( nativeApp, Float(webView.bounds.width), Float(webView.bounds.height), scale, nil, Float(safe.top), Float(safe.right), Float(safe.bottom), Float(safe.left), 0, 0, Float(keyboardBottomInset), 0 ) native_sdk_app_frame(nativeApp) refreshWidgetAccessibility() } private func widgetSemanticsSnapshot() -> [WidgetSemantics] { guard let nativeApp else { return [] } let count = Int(native_sdk_app_widget_semantics_count(nativeApp)) var nodes: [WidgetSemantics] = [] nodes.reserveCapacity(count) for index in 0.. WidgetSemantics? { guard let nativeApp else { return nil } var node = native_sdk_widget_semantics_t() guard native_sdk_app_widget_semantics_at(nativeApp, UInt(index), &node) != 0 else { return nil } return widgetSemantics(from: node) } private func widgetSemantics(id: UInt64) -> WidgetSemantics? { guard let nativeApp else { return nil } var node = native_sdk_widget_semantics_t() guard native_sdk_app_widget_semantics_by_id(nativeApp, id, &node) != 0 else { return nil } return widgetSemantics(from: node) } private func widgetSemantics(from node: native_sdk_widget_semantics_t) -> WidgetSemantics { return WidgetSemantics( id: node.id, parentId: node.parent_id, role: Int32(node.role), flags: node.flags, actions: node.actions, bounds: CGRect(x: CGFloat(node.x), y: CGFloat(node.y), width: CGFloat(node.width), height: CGFloat(node.height)), value: node.has_value != 0 ? node.value : nil, label: Self.utf8String(node.label, length: node.label_len), text: Self.utf8String(node.text, length: node.text_len), textSelectionStart: Int(node.text_selection_start), textSelectionEnd: Int(node.text_selection_end), textCompositionStart: Int(node.text_composition_start), textCompositionEnd: Int(node.text_composition_end), gridRowIndex: Int(node.grid_row_index), gridColumnIndex: Int(node.grid_column_index), gridRowCount: Int(node.grid_row_count), gridColumnCount: Int(node.grid_column_count), listItemIndex: Int(node.list_item_index), listItemCount: Int(node.list_item_count), scrollOffset: node.scroll_offset, scrollViewportExtent: node.scroll_viewport_extent, scrollContentExtent: node.scroll_content_extent, hasScroll: node.has_scroll != 0 ) } private func widgetTextGeometry(id: UInt64) -> WidgetTextGeometry? { guard let nativeApp else { return nil } var geometry = native_sdk_widget_text_geometry_t() guard native_sdk_app_widget_text_geometry(nativeApp, id, &geometry) != 0 else { return nil } return WidgetTextGeometry( id: id, caretBounds: geometry.has_caret_bounds != 0 ? CGRect(x: CGFloat(geometry.caret_x), y: CGFloat(geometry.caret_y), width: CGFloat(geometry.caret_width), height: CGFloat(geometry.caret_height)) : nil, selectionBounds: geometry.has_selection_bounds != 0 ? CGRect(x: CGFloat(geometry.selection_x), y: CGFloat(geometry.selection_y), width: CGFloat(geometry.selection_width), height: CGFloat(geometry.selection_height)) : nil, selectionRectCount: Int(geometry.selection_rect_count), compositionBounds: geometry.has_composition_bounds != 0 ? CGRect(x: CGFloat(geometry.composition_x), y: CGFloat(geometry.composition_y), width: CGFloat(geometry.composition_width), height: CGFloat(geometry.composition_height)) : nil, compositionRectCount: Int(geometry.composition_rect_count) ) } @discardableResult private func dispatchWidgetAction( id: UInt64, action: Int32, text: String? = nil, selectionAnchor: UInt = 0, selectionFocus: UInt = 0, hasSelection: Bool = false ) -> Bool { guard let nativeApp else { return false } var request = native_sdk_widget_action_t() request.id = id request.action = action request.selection_anchor = selectionAnchor request.selection_focus = selectionFocus request.has_selection = hasSelection ? 1 : 0 let ok: Int32 if let text { ok = text.withCString { pointer in request.text = pointer request.text_len = UInt(text.utf8.count) return native_sdk_app_widget_action(nativeApp, &request) } } else { request.text = nil request.text_len = 0 ok = native_sdk_app_widget_action(nativeApp, &request) } if ok != 0 { native_sdk_app_frame(nativeApp) refreshWidgetAccessibility() } return ok != 0 } private func refreshWidgetAccessibility() { let semantics = widgetSemanticsSnapshot() statusLabel.accessibilityValue = "Accessible items: \(semantics.count)" widgetAccessibilityElements = semantics.map { node in let element = WidgetAccessibilityElement(accessibilityContainer: webView, owner: self, node: node) element.accessibilityIdentifier = "native-sdk-widget-\(node.id)" element.accessibilityLabel = node.label.isEmpty ? node.text : node.label element.accessibilityValue = widgetAccessibilityValue(node) element.accessibilityFrameInContainerSpace = node.bounds element.accessibilityTraits = widgetAccessibilityTraits(node) return element } webView.accessibilityElements = widgetAccessibilityElements.isEmpty ? nil : widgetAccessibilityElements as [Any] } private func widgetAccessibilityValue(_ node: WidgetSemantics) -> String? { var states: [String] = [] if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_EXPANDED)) != 0 { states.append("Expanded") } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_COLLAPSED)) != 0 { states.append("Collapsed") } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_REQUIRED)) != 0 { states.append("Required") } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_READ_ONLY)) != 0 { states.append("Read only") } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_INVALID)) != 0 { states.append("Invalid") } if !states.isEmpty { return states.joined(separator: ", ") } if let value = node.value { switch node.role { case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH): return value >= 0.5 ? "On" : "Off" case Int32(NATIVE_SDK_WIDGET_ROLE_SLIDER), Int32(NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR): return "\(Int((value * 100).rounded()))%" default: return "\(value)" } } return node.text.isEmpty ? nil : node.text } private func activateWidgetAccessibilityNode(_ node: WidgetSemantics) -> Bool { let current = widgetSemantics(id: node.id) ?? node if widgetSupportsAction(current, UInt32(NATIVE_SDK_WIDGET_ACTION_TOGGLE)) { return dispatchWidgetAction(id: current.id, action: Int32(NATIVE_SDK_WIDGET_ACTION_KIND_TOGGLE)) } if widgetSupportsAction(current, UInt32(NATIVE_SDK_WIDGET_ACTION_PRESS)) { return dispatchWidgetAction(id: current.id, action: Int32(NATIVE_SDK_WIDGET_ACTION_KIND_PRESS)) } if widgetSupportsAction(current, UInt32(NATIVE_SDK_WIDGET_ACTION_SELECT)) { return dispatchWidgetAction(id: current.id, action: Int32(NATIVE_SDK_WIDGET_ACTION_KIND_SELECT)) } return false } private func incrementWidgetAccessibilityNode(_ node: WidgetSemantics) -> Bool { let current = widgetSemantics(id: node.id) ?? node guard widgetSupportsAction(current, UInt32(NATIVE_SDK_WIDGET_ACTION_INCREMENT)) else { return false } return dispatchWidgetAction(id: current.id, action: Int32(NATIVE_SDK_WIDGET_ACTION_KIND_INCREMENT)) } private func decrementWidgetAccessibilityNode(_ node: WidgetSemantics) -> Bool { let current = widgetSemantics(id: node.id) ?? node guard widgetSupportsAction(current, UInt32(NATIVE_SDK_WIDGET_ACTION_DECREMENT)) else { return false } return dispatchWidgetAction(id: current.id, action: Int32(NATIVE_SDK_WIDGET_ACTION_KIND_DECREMENT)) } private func widgetSupportsAction(_ node: WidgetSemantics, _ action: UInt32) -> Bool { return (node.actions & action) != 0 } private func widgetAccessibilityTraits(_ node: WidgetSemantics) -> UIAccessibilityTraits { var traits: UIAccessibilityTraits = [] switch node.role { case Int32(NATIVE_SDK_WIDGET_ROLE_BUTTON), Int32(NATIVE_SDK_WIDGET_ROLE_MENUITEM): traits.insert(.button) case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH), Int32(NATIVE_SDK_WIDGET_ROLE_TAB): traits.insert(.button) case Int32(NATIVE_SDK_WIDGET_ROLE_SLIDER): traits.insert(.adjustable) case Int32(NATIVE_SDK_WIDGET_ROLE_IMAGE): traits.insert(.image) case Int32(NATIVE_SDK_WIDGET_ROLE_TEXT), Int32(NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR): traits.insert(.staticText) default: break } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_SELECTED)) != 0 { traits.insert(.selected) } if (node.flags & UInt32(NATIVE_SDK_WIDGET_FLAG_DISABLED)) != 0 { traits.insert(.notEnabled) } return traits } private static func utf8String(_ pointer: UnsafePointer?, length: UInt) -> String { guard let pointer, length > 0 else { return "" } let bytes = UnsafeBufferPointer(start: UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self), count: Int(length)) return String(decoding: bytes, as: UTF8.self) } deinit { NotificationCenter.default.removeObserver(self) guard let nativeApp else { return } native_sdk_app_stop(nativeApp) native_sdk_app_destroy(nativeApp) } private static let html = """

Workspace

This content is rendered by WKWebView while the header remains native UIKit.

Inbox review
Sync queue
Offline cache
""" }