WIP on making the safe area on iOS track the keyboard as well

This commit is contained in:
Mads Marquart
2026-03-18 01:05:58 +01:00
parent 4f29aed5ee
commit 3a2b140d65
7 changed files with 186 additions and 20 deletions

View File

@@ -752,10 +752,11 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
/// The inset area of the surface that is unobstructed. /// The inset area of the surface that is unobstructed.
/// ///
/// On some devices, especially mobile devices, the screen is not a perfect rectangle, and may /// 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 /// have rounded corners, notches, bezels, and so on. Additionally, a soft keyboard may be open.
/// want to draw your background and other such unimportant content on the entire surface, while /// When drawing your content, you usually want to draw your background and other such
/// you will want to restrict important content such as text, interactable or visual indicators /// unimportant content on the entire surface, while you will want to restrict important content
/// to the part of the screen that is actually visible; for this, you use the safe area. /// 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 /// 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 /// of the surface, and the size extending downwards to the right. The area will not extend

View File

@@ -32,6 +32,7 @@ objc2-core-foundation = { workspace = true, features = [
"CFRunLoop", "CFRunLoop",
"CFString", "CFString",
] } ] }
objc2-core-graphics = { workspace = true, features = ["std", "CGGeometry"] }
objc2-foundation = { workspace = true, features = [ objc2-foundation = { workspace = true, features = [
"std", "std",
"block2", "block2",
@@ -47,14 +48,13 @@ objc2-foundation = { workspace = true, features = [
] } ] }
objc2-ui-kit = { workspace = true, features = [ objc2-ui-kit = { workspace = true, features = [
"std", "std",
"block2",
"objc2-core-foundation", "objc2-core-foundation",
"UIApplication", "UIApplication",
"UIDevice", "UIDevice",
"UIEvent", "UIEvent",
"UIGeometry", "UIGeometry",
"UIGestureRecognizer", "UIGestureRecognizer",
"UITextInput",
"UITextInputTraits",
"UIOrientation", "UIOrientation",
"UIPanGestureRecognizer", "UIPanGestureRecognizer",
"UIPinchGestureRecognizer", "UIPinchGestureRecognizer",
@@ -63,10 +63,14 @@ objc2-ui-kit = { workspace = true, features = [
"UIScreen", "UIScreen",
"UIScreenMode", "UIScreenMode",
"UITapGestureRecognizer", "UITapGestureRecognizer",
"UITextInput",
"UITextInputTraits",
"UITouch", "UITouch",
"UITraitCollection", "UITraitCollection",
"UIView", "UIView",
"UIViewAnimating",
"UIViewController", "UIViewController",
"UIViewPropertyAnimator",
"UIWindow", "UIWindow",
] } ] }
winit-common = { workspace = true, features = ["core-foundation", "event-handler"] } winit-common = { workspace = true, features = ["core-foundation", "event-handler"] }

View File

@@ -309,7 +309,7 @@ pub(crate) fn handle_nonuser_events<I: IntoIterator<Item = EventWrapper>>(
for event in events { for event in events {
if !processing_redraws && event.is_redraw() { 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() { } else if processing_redraws && !event.is_redraw() {
tracing::warn!( tracing::warn!(
"processing non `RedrawRequested` event after the main event loop: {:#?}", "processing non `RedrawRequested` event after the main event loop: {:#?}",
@@ -327,7 +327,7 @@ pub(crate) fn handle_nonuser_events<I: IntoIterator<Item = EventWrapper>>(
for event in queued_events { for event in queued_events {
if !processing_redraws && event.is_redraw() { 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() { } else if processing_redraws && !event.is_redraw() {
tracing::warn!( tracing::warn!(
"processing non-`RedrawRequested` event after the main event loop: {:#?}", "processing non-`RedrawRequested` event after the main event loop: {:#?}",

View File

@@ -124,7 +124,7 @@ define_class!(
#[unsafe(method(safeAreaInsetsDidChange))] #[unsafe(method(safeAreaInsetsDidChange))]
fn safe_area_changed(&self) { 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 // When the safe area changes we want to make sure to emit a redraw event
self.setNeedsDisplay(); self.setNeedsDisplay();
} }

View File

@@ -1,13 +1,26 @@
use std::cell::Cell; use std::cell::Cell;
use objc2::rc::Retained; use block2::RcBlock;
use objc2::{DefinedClass, MainThreadMarker, available, define_class, msg_send}; use objc2::rc::{Retained, Weak};
use objc2_foundation::NSObject; 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::{ use objc2_ui_kit::{
UIDevice, UIInterfaceOrientationMask, UIRectEdge, UIResponder, UIStatusBarStyle, UICoordinateSpace, UIDevice, UIEdgeInsets, UIInterfaceOrientationMask,
UIUserInterfaceIdiom, UIView, UIViewController, 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}; use crate::{ScreenEdge, StatusBarStyle, ValidOrientations, WindowAttributesIos};
pub struct ViewControllerState { pub struct ViewControllerState {
@@ -16,6 +29,9 @@ pub struct ViewControllerState {
prefers_home_indicator_auto_hidden: Cell<bool>, prefers_home_indicator_auto_hidden: Cell<bool>,
supported_orientations: Cell<UIInterfaceOrientationMask>, supported_orientations: Cell<UIInterfaceOrientationMask>,
preferred_screen_edges_deferring_system_gestures: Cell<UIRectEdge>, preferred_screen_edges_deferring_system_gestures: Cell<UIRectEdge>,
// Keep observer around (deallocating it stops notifications being posted).
keyboard_will_change_frame_observer:
Cell<Option<Retained<ProtocolObject<dyn NSObjectProtocol>>>>,
} }
define_class!( define_class!(
@@ -138,6 +154,7 @@ impl WinitViewController {
prefers_home_indicator_auto_hidden: Cell::new(false), prefers_home_indicator_auto_hidden: Cell::new(false),
supported_orientations: Cell::new(UIInterfaceOrientationMask::All), supported_orientations: Cell::new(UIInterfaceOrientationMask::All),
preferred_screen_edges_deferring_system_gestures: Cell::new(UIRectEdge::empty()), preferred_screen_edges_deferring_system_gestures: Cell::new(UIRectEdge::empty()),
keyboard_will_change_frame_observer: Cell::new(None),
}); });
let this: Retained<Self> = unsafe { msg_send![super(this), init] }; let this: Retained<Self> = unsafe { msg_send![super(this), init] };
@@ -153,8 +170,115 @@ impl WinitViewController {
ios_attributes.preferred_screen_edges_deferring_system_gestures, ios_attributes.preferred_screen_edges_deferring_system_gestures,
); );
let center = NSNotificationCenter::defaultCenter();
this.setView(Some(view)); 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(
&center,
unsafe { UIKeyboardWillChangeFrameNotification },
move |notification| {
eprintln!("UIKeyboardWillChangeFrameNotification");
if let Some(controller) = controller_weak.load() {
keyboard_will_change_frame(&controller, notification);
}
},
)));
this 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::<UIScreen>().unwrap())
.unwrap_or_else(|| view.window().unwrap().screen());
let user_info = notification.userInfo().unwrap();
let begin_frame = user_info
.objectForKey(unsafe { UIKeyboardFrameBeginUserInfoKey })
.unwrap()
.downcast::<NSValue>()
.unwrap()
.get_rect()
.unwrap();
let end_frame = user_info
.objectForKey(unsafe { UIKeyboardFrameEndUserInfoKey })
.unwrap()
.downcast::<NSValue>()
.unwrap()
.get_rect()
.unwrap();
let duration: CFTimeInterval = user_info
.objectForKey(unsafe { UIKeyboardAnimationDurationUserInfoKey })
.unwrap()
.downcast::<NSNumber>()
.unwrap()
.doubleValue();
let curve_raw = user_info
.objectForKey(unsafe { UIKeyboardAnimationCurveUserInfoKey })
.unwrap()
.downcast::<NSNumber>()
.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,
// );
} }

View File

@@ -11,6 +11,7 @@ use dpi::{
use objc2::rc::Retained; use objc2::rc::Retained;
use objc2::{MainThreadMarker, available, class, define_class, msg_send}; use objc2::{MainThreadMarker, available, class, define_class, msg_send};
use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize};
use objc2_core_graphics::CGRectIntersection;
use objc2_foundation::{NSObject, NSObjectProtocol}; use objc2_foundation::{NSObject, NSObjectProtocol};
use objc2_ui_kit::{ use objc2_ui_kit::{
UIApplication, UICoordinateSpace, UIEdgeInsets, UIResponder, UIScreen, UIApplication, UICoordinateSpace, UIEdgeInsets, UIResponder, UIScreen,
@@ -202,7 +203,7 @@ impl Inner {
} }
pub fn safe_area(&self) -> PhysicalInsets<u32> { pub fn safe_area(&self) -> PhysicalInsets<u32> {
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() self.view.safeAreaInsets()
} else { } else {
// Assume the status bar frame is the only thing that obscures the view // 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(); let status_bar_frame = app.statusBarFrame();
UIEdgeInsets { top: status_bar_frame.size.height, left: 0.0, bottom: 0.0, right: 0.0 } 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()) insets.to_physical(self.scale_factor())
} }

View File

@@ -2,12 +2,16 @@
use std::error::Error; use std::error::Error;
use dpi::PhysicalInsets;
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::event::WindowEvent; use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::event_loop::{ActiveEventLoop, EventLoop};
#[cfg(web_platform)] #[cfg(web_platform)]
use winit::platform::web::WindowAttributesWeb; 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"] #[path = "util/fill.rs"]
mod fill; mod fill;
@@ -17,6 +21,7 @@ mod tracing;
#[derive(Default, Debug)] #[derive(Default, Debug)]
struct App { struct App {
window: Option<Box<dyn Window>>, window: Option<Box<dyn Window>>,
prev_safe_area: PhysicalInsets<u32>,
} }
impl ApplicationHandler for App { impl ApplicationHandler for App {
@@ -33,11 +38,24 @@ impl ApplicationHandler for App {
event_loop.exit(); event_loop.exit();
return; 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) { 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 { match event {
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
println!("Close was requested; stopping"); 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(); self.window.as_ref().expect("resize event without a window").request_redraw();
}, },
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
println!("redraw");
// Redraw the application. // Redraw the application.
// //
// It's preferable for applications that do not render continuously to render in // 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. // For contiguous redraw loop you can request a redraw from here.
// window.request_redraw(); // 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<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {