From 3a2b140d6538e9859aadc25b084395fdcec0b6b3 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 18 Mar 2026 01:05:58 +0100 Subject: [PATCH] WIP on making the safe area on iOS track the keyboard as well --- winit-core/src/window.rs | 9 +- winit-uikit/Cargo.toml | 8 +- winit-uikit/src/app_state.rs | 4 +- winit-uikit/src/view.rs | 2 +- winit-uikit/src/view_controller.rs | 134 +++++++++++++++++++++++++++-- winit-uikit/src/window.rs | 15 +++- winit/examples/window.rs | 34 +++++++- 7 files changed, 186 insertions(+), 20 deletions(-) diff --git a/winit-core/src/window.rs b/winit-core/src/window.rs index 6f564ba900..45ae342cd2 100644 --- a/winit-core/src/window.rs +++ b/winit-core/src/window.rs @@ -752,10 +752,11 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// The inset area of the surface that is unobstructed. /// /// On some devices, especially mobile devices, the screen is not a perfect rectangle, and may - /// have rounded corners, notches, bezels, and so on. When drawing your content, you usually - /// want to draw your background and other such unimportant content on the entire surface, while - /// you will want to restrict important content such as text, interactable or visual indicators - /// to the part of the screen that is actually visible; for this, you use the safe area. + /// have rounded corners, notches, bezels, and so on. Additionally, a soft keyboard may be open. + /// When drawing your content, you usually want to draw your background and other such + /// unimportant content on the entire surface, while you will want to restrict important content + /// such as text, interactable or visual indicators to the part of the screen that is actually + /// visible; for this, you use the safe area. /// /// The safe area is a rectangle that is defined relative to the origin at the top-left corner /// of the surface, and the size extending downwards to the right. The area will not extend diff --git a/winit-uikit/Cargo.toml b/winit-uikit/Cargo.toml index 3784bc3237..4d2e9da140 100644 --- a/winit-uikit/Cargo.toml +++ b/winit-uikit/Cargo.toml @@ -32,6 +32,7 @@ objc2-core-foundation = { workspace = true, features = [ "CFRunLoop", "CFString", ] } +objc2-core-graphics = { workspace = true, features = ["std", "CGGeometry"] } objc2-foundation = { workspace = true, features = [ "std", "block2", @@ -47,14 +48,13 @@ objc2-foundation = { workspace = true, features = [ ] } objc2-ui-kit = { workspace = true, features = [ "std", + "block2", "objc2-core-foundation", "UIApplication", "UIDevice", "UIEvent", "UIGeometry", "UIGestureRecognizer", - "UITextInput", - "UITextInputTraits", "UIOrientation", "UIPanGestureRecognizer", "UIPinchGestureRecognizer", @@ -63,10 +63,14 @@ objc2-ui-kit = { workspace = true, features = [ "UIScreen", "UIScreenMode", "UITapGestureRecognizer", + "UITextInput", + "UITextInputTraits", "UITouch", "UITraitCollection", "UIView", + "UIViewAnimating", "UIViewController", + "UIViewPropertyAnimator", "UIWindow", ] } winit-common = { workspace = true, features = ["core-foundation", "event-handler"] } diff --git a/winit-uikit/src/app_state.rs b/winit-uikit/src/app_state.rs index 7876e34fe1..05c143e911 100644 --- a/winit-uikit/src/app_state.rs +++ b/winit-uikit/src/app_state.rs @@ -309,7 +309,7 @@ pub(crate) fn handle_nonuser_events>( for event in events { if !processing_redraws && event.is_redraw() { - tracing::info!("processing `RedrawRequested` during the main event loop"); + // tracing::info!("processing `RedrawRequested` during the main event loop"); } else if processing_redraws && !event.is_redraw() { tracing::warn!( "processing non `RedrawRequested` event after the main event loop: {:#?}", @@ -327,7 +327,7 @@ pub(crate) fn handle_nonuser_events>( for event in queued_events { if !processing_redraws && event.is_redraw() { - tracing::info!("processing `RedrawRequested` during the main event loop"); + // tracing::info!("processing `RedrawRequested` during the main event loop"); } else if processing_redraws && !event.is_redraw() { tracing::warn!( "processing non-`RedrawRequested` event after the main event loop: {:#?}", diff --git a/winit-uikit/src/view.rs b/winit-uikit/src/view.rs index 96b2647886..20bb3d19e5 100644 --- a/winit-uikit/src/view.rs +++ b/winit-uikit/src/view.rs @@ -124,7 +124,7 @@ define_class!( #[unsafe(method(safeAreaInsetsDidChange))] fn safe_area_changed(&self) { - debug!("safeAreaInsetsDidChange was called, requesting redraw"); + println!("safeAreaInsetsDidChange was called, requesting redraw"); // When the safe area changes we want to make sure to emit a redraw event self.setNeedsDisplay(); } diff --git a/winit-uikit/src/view_controller.rs b/winit-uikit/src/view_controller.rs index bc8607c2d3..02a6ecc837 100644 --- a/winit-uikit/src/view_controller.rs +++ b/winit-uikit/src/view_controller.rs @@ -1,13 +1,26 @@ use std::cell::Cell; -use objc2::rc::Retained; -use objc2::{DefinedClass, MainThreadMarker, available, define_class, msg_send}; -use objc2_foundation::NSObject; +use block2::RcBlock; +use objc2::rc::{Retained, Weak}; +use objc2::runtime::ProtocolObject; +use objc2::{ + DefinedClass, MainThreadMarker, MainThreadOnly, Message, available, define_class, msg_send, +}; +use objc2_core_foundation::{CFTimeInterval, CGRect}; +use objc2_core_graphics::CGRectIntersection; +use objc2_foundation::{ + NSNotification, NSNotificationCenter, NSNumber, NSObject, NSObjectProtocol, NSValue, +}; use objc2_ui_kit::{ - UIDevice, UIInterfaceOrientationMask, UIRectEdge, UIResponder, UIStatusBarStyle, - UIUserInterfaceIdiom, UIView, UIViewController, + UICoordinateSpace, UIDevice, UIEdgeInsets, UIInterfaceOrientationMask, + UIKeyboardAnimationCurveUserInfoKey, UIKeyboardAnimationDurationUserInfoKey, + UIKeyboardFrameBeginUserInfoKey, UIKeyboardFrameEndUserInfoKey, + UIKeyboardWillChangeFrameNotification, UIRectEdge, UIResponder, UIScreen, UIStatusBarStyle, + UIUserInterfaceIdiom, UIView, UIViewAnimating, UIViewAnimationCurve, UIViewAnimationOptions, + UIViewController, UIViewPropertyAnimator, }; +use crate::notification_center::create_observer; use crate::{ScreenEdge, StatusBarStyle, ValidOrientations, WindowAttributesIos}; pub struct ViewControllerState { @@ -16,6 +29,9 @@ pub struct ViewControllerState { prefers_home_indicator_auto_hidden: Cell, supported_orientations: Cell, preferred_screen_edges_deferring_system_gestures: Cell, + // Keep observer around (deallocating it stops notifications being posted). + keyboard_will_change_frame_observer: + Cell>>>, } define_class!( @@ -138,6 +154,7 @@ impl WinitViewController { prefers_home_indicator_auto_hidden: Cell::new(false), supported_orientations: Cell::new(UIInterfaceOrientationMask::All), preferred_screen_edges_deferring_system_gestures: Cell::new(UIRectEdge::empty()), + keyboard_will_change_frame_observer: Cell::new(None), }); let this: Retained = unsafe { msg_send![super(this), init] }; @@ -153,8 +170,115 @@ impl WinitViewController { ios_attributes.preferred_screen_edges_deferring_system_gestures, ); + let center = NSNotificationCenter::defaultCenter(); + this.setView(Some(view)); + // Set up an observer that will make the `safeAreaRect` of the view update based on the soft + // keyboard's presence (in addition to everything else that the safe area depends on). + let controller_weak = Weak::from_retained(&this); + this.ivars().keyboard_will_change_frame_observer.set(Some(create_observer( + ¢er, + unsafe { UIKeyboardWillChangeFrameNotification }, + move |notification| { + eprintln!("UIKeyboardWillChangeFrameNotification"); + if let Some(controller) = controller_weak.load() { + keyboard_will_change_frame(&controller, notification); + } + }, + ))); + this } + + /// The current keyboard frame, in the view's coordinate space. + pub(crate) fn current_keyboard_frame(&self) -> CGRect { + // TODO: Combine start_frame and end_frame with `animator.fractionComplete()` to produce + // current frame + + // Convert keyboard frame to view coordinates. + let keyboard_frame = self + .view() + .unwrap() + .convertRect_fromCoordinateSpace(frame, &keyboard_screen.coordinateSpace()); + todo!() + } +} + +fn keyboard_will_change_frame(controller: &WinitViewController, notification: &NSNotification) { + let mtm = controller.mtm(); + let controller = controller.retain(); + let view = controller.view().unwrap(); + + // The notification's object is the screen the keyboard appears on (since iOS 16). + let keyboard_screen = notification + .object() + .map(|s| s.downcast::().unwrap()) + .unwrap_or_else(|| view.window().unwrap().screen()); + + let user_info = notification.userInfo().unwrap(); + let begin_frame = user_info + .objectForKey(unsafe { UIKeyboardFrameBeginUserInfoKey }) + .unwrap() + .downcast::() + .unwrap() + .get_rect() + .unwrap(); + let end_frame = user_info + .objectForKey(unsafe { UIKeyboardFrameEndUserInfoKey }) + .unwrap() + .downcast::() + .unwrap() + .get_rect() + .unwrap(); + let duration: CFTimeInterval = user_info + .objectForKey(unsafe { UIKeyboardAnimationDurationUserInfoKey }) + .unwrap() + .downcast::() + .unwrap() + .doubleValue(); + let curve_raw = user_info + .objectForKey(unsafe { UIKeyboardAnimationCurveUserInfoKey }) + .unwrap() + .downcast::() + .unwrap() + .integerValue(); + let curve = UIViewAnimationCurve(curve_raw); + + // If OS version is high enough, set up a `UIViewPropertyAnimator` to track the position of the + // keyboard. + if available!(ios = 10.0, tvos = 10.0, visionos = 1.0) { + let animator = UIViewPropertyAnimator::initWithDuration_curve_animations( + UIViewPropertyAnimator::alloc(mtm), + duration, + curve, + None, + ); + + animator.addCompletion(&RcBlock::new(move |_| { + controller.setAdditionalSafeAreaInsets(todo!()); + // TODO: Might need to do further work to update the safe area when we + // move the view? + + view.layoutIfNeeded(); + // Safe area changed -> request redraw. + view.setNeedsDisplay(); + })); + + animator.startAnimation(); + } else { + // Update immediately. + todo!() + } + + // Not sufficient, `setAdditionalSafeAreaInsets` only updates at the start, it doesn't change + // `safeAreaInsets` continously during the keyboard open animation. + // + // UIView::animateWithDuration_delay_options_animations_completion( + // duration, + // 0.0, + // options, + // None, + // mtm, + // ); } diff --git a/winit-uikit/src/window.rs b/winit-uikit/src/window.rs index 788babcb71..a7ee446a32 100644 --- a/winit-uikit/src/window.rs +++ b/winit-uikit/src/window.rs @@ -11,6 +11,7 @@ use dpi::{ use objc2::rc::Retained; use objc2::{MainThreadMarker, available, class, define_class, msg_send}; use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; +use objc2_core_graphics::CGRectIntersection; use objc2_foundation::{NSObject, NSObjectProtocol}; use objc2_ui_kit::{ UIApplication, UICoordinateSpace, UIEdgeInsets, UIResponder, UIScreen, @@ -202,7 +203,7 @@ impl Inner { } pub fn safe_area(&self) -> PhysicalInsets { - let insets = if available!(ios = 11.0, tvos = 11.0, visionos = 1.0) { + let device_insets = if available!(ios = 11.0, tvos = 11.0, visionos = 1.0) { self.view.safeAreaInsets() } else { // Assume the status bar frame is the only thing that obscures the view @@ -211,7 +212,17 @@ impl Inner { let status_bar_frame = app.statusBarFrame(); UIEdgeInsets { top: status_bar_frame.size.height, left: 0.0, bottom: 0.0, right: 0.0 } }; - let insets = LogicalInsets::new(insets.top, insets.left, insets.bottom, insets.right); + + let keyboard_frame = self.view_controller.current_keyboard_frame(); + let intersection = CGRectIntersection(self.view.bounds(), keyboard_frame); + + let insets = LogicalInsets::new( + device_insets.top, + device_insets.left, + // Assume that the keyboard appears from the bottom. + device_insets.bottom + intersection.size.height, + device_insets.right, + ); insets.to_physical(self.scale_factor()) } diff --git a/winit/examples/window.rs b/winit/examples/window.rs index 76c60c39e8..b7bf39c042 100644 --- a/winit/examples/window.rs +++ b/winit/examples/window.rs @@ -2,12 +2,16 @@ use std::error::Error; +use dpi::PhysicalInsets; use winit::application::ApplicationHandler; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, EventLoop}; #[cfg(web_platform)] use winit::platform::web::WindowAttributesWeb; -use winit::window::{Window, WindowAttributes, WindowId}; +use winit::window::{ + ImeCapabilities, ImeEnableRequest, ImeRequest, ImeRequestData, Window, WindowAttributes, + WindowId, +}; #[path = "util/fill.rs"] mod fill; @@ -17,6 +21,7 @@ mod tracing; #[derive(Default, Debug)] struct App { window: Option>, + prev_safe_area: PhysicalInsets, } impl ApplicationHandler for App { @@ -33,11 +38,24 @@ impl ApplicationHandler for App { event_loop.exit(); return; }, - } + }; + + // Allow IME out of the box. + let enable_request = + ImeEnableRequest::new(ImeCapabilities::new(), ImeRequestData::default()).unwrap(); + let enable_ime = ImeRequest::Enable(enable_request); + + // Initial update + self.window.as_ref().unwrap().request_ime_update(enable_ime).unwrap(); } fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _: WindowId, event: WindowEvent) { - println!("{event:?}"); + let current_safe_area = self.window.as_ref().unwrap().safe_area(); + if self.prev_safe_area != current_safe_area { + println!("safe area changed from {:?} to {:?}", self.prev_safe_area, current_safe_area); + self.prev_safe_area = current_safe_area; + } + match event { WindowEvent::CloseRequested => { println!("Close was requested; stopping"); @@ -47,6 +65,7 @@ impl ApplicationHandler for App { self.window.as_ref().expect("resize event without a window").request_redraw(); }, WindowEvent::RedrawRequested => { + println!("redraw"); // Redraw the application. // // It's preferable for applications that do not render continuously to render in @@ -64,9 +83,16 @@ impl ApplicationHandler for App { // For contiguous redraw loop you can request a redraw from here. // window.request_redraw(); }, - _ => (), + _ => { + println!("{event:?}"); + }, } } + + fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { + let window = self.window.as_ref().expect("redraw request without a window"); + // window.request_redraw(); + } } fn main() -> Result<(), Box> {