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.
///
/// 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

View File

@@ -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"] }

View File

@@ -309,7 +309,7 @@ pub(crate) fn handle_nonuser_events<I: IntoIterator<Item = EventWrapper>>(
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<I: IntoIterator<Item = EventWrapper>>(
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: {:#?}",

View File

@@ -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();
}

View File

@@ -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<bool>,
supported_orientations: Cell<UIInterfaceOrientationMask>,
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!(
@@ -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<Self> = 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(
&center,
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::<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::{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<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()
} 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())
}

View File

@@ -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<Box<dyn Window>>,
prev_safe_area: PhysicalInsets<u32>,
}
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<dyn Error>> {