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

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