#[cfg(not(target_os = "ios"))]
compile_error!("the UIKit sheet arm targets iOS only");
use std::cell::Cell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::NonNull;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use block2::RcBlock;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject};
use objc2::{
ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send, sel,
};
use objc2_core_foundation::CGFloat;
use objc2_foundation::{NSArray, NSData, NSNotification, NSNotificationCenter, NSString};
use objc2_ui_kit::{
NSLayoutConstraint, UIAdaptivePresentationControllerDelegate, UIButton, UIButtonType, UIColor,
UIControlEvents, UIControlState, UIFont, UIImage, UIImageView, UILabel, UILayoutConstraintAxis,
UIModalPresentationStyle, UIPresentationController, UIResponder,
UISceneDidDisconnectNotification, UISheetPresentationController,
UISheetPresentationControllerDelegate, UISheetPresentationControllerDetent,
UISheetPresentationControllerDetentIdentifierLarge,
UISheetPresentationControllerDetentIdentifierMedium,
UISheetPresentationControllerDetentInactive,
UISheetPresentationControllerDetentResolutionContext, UIStackView, UIStackViewAlignment,
UIUserInterfaceStyle, UIView, UIViewContentMode, UIViewController,
};
use super::apple_host::{
AfterDismiss, DisplacedAction, LivePresentation, dismiss_live, displaced_action, install_live,
on_main, presenting_anchor, take_live_as,
};
use super::{
ActionRole, Detent, DismissReason, PresentError, Sender, SheetHost, SheetOutcome, SheetSpec,
};
use crate::controls::platform::{set_label_font, set_label_text_color, ui_color};
const IMAGE_MAX_HEIGHT: CGFloat = 200.0;
const ROW_SPACING: CGFloat = 12.0;
const TOP_INSET: CGFloat = 28.0;
const SIDE_INSET: CGFloat = 20.0;
const BOTTOM_INSET: CGFloat = 20.0;
const ACTION_MIN_HEIGHT: CGFloat = 44.0;
type DetentResolver = RcBlock<
dyn Fn(
NonNull<ProtocolObject<dyn UISheetPresentationControllerDetentResolutionContext>>,
) -> CGFloat,
>;
pub(crate) struct Host;
impl SheetHost for Host {
fn show_sheet(
spec: SheetSpec,
tx: Sender<SheetOutcome>,
generation: u64,
) -> Result<(), PresentError> {
on_main(move |mtm| start(mtm, spec, tx, generation));
Ok(())
}
fn dismiss(generation: u64) {
dismiss_live(generation);
}
fn select_detent(generation: u64, detent: Detent) {
on_main(move |mtm| {
if let Some(live) = take_live_as::<LiveSheet>(mtm, generation) {
live.select(detent);
park(mtm, generation, live);
}
});
}
}
fn custom_detents_available() -> bool {
UISheetPresentationControllerDetent::class()
.metaclass()
.responds_to(sel!(customDetentWithIdentifier:resolver:))
}
fn effective_detent(detent: Detent, custom_available: bool) -> Detent {
if custom_available || !matches!(detent, Detent::Custom(_)) {
return detent;
}
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
log::warn!(
"frust-native-widgets: custom sheet detents need iOS 16; substituting the nearest of \
medium/large on this OS"
);
}
detent.system_fallback()
}
fn identifier(detent: Detent) -> Retained<NSString> {
match detent {
Detent::Medium => unsafe { UISheetPresentationControllerDetentIdentifierMedium }.retain(),
Detent::Large => unsafe { UISheetPresentationControllerDetentIdentifierLarge }.retain(),
Detent::Custom(fraction) => NSString::from_str(&format!(
"dev.frust.nativewidgets.sheet.custom.{:016x}",
fraction.to_bits()
)),
}
}
fn detent_object(
mtm: MainThreadMarker,
detent: Detent,
resolvers: &mut Vec<DetentResolver>,
) -> Retained<UISheetPresentationControllerDetent> {
match detent {
Detent::Medium => UISheetPresentationControllerDetent::mediumDetent(mtm),
Detent::Large => UISheetPresentationControllerDetent::largeDetent(mtm),
Detent::Custom(fraction) => {
let resolver: DetentResolver = RcBlock::new(
move |context: NonNull<
ProtocolObject<dyn UISheetPresentationControllerDetentResolutionContext>,
>| {
catch_unwind(AssertUnwindSafe(|| {
let context = unsafe { context.as_ref() };
context.maximumDetentValue() * fraction
}))
.unwrap_or(unsafe { UISheetPresentationControllerDetentInactive })
},
);
let object = UISheetPresentationControllerDetent::customDetentWithIdentifier_resolver(
Some(&identifier(detent)),
&resolver,
mtm,
);
resolvers.push(resolver);
object
}
}
}
fn start(mtm: MainThreadMarker, spec: SheetSpec, tx: Sender<SheetOutcome>, generation: u64) {
let live = LiveSheet::build(mtm, &spec, tx, generation);
match install_live(mtm, generation, Box::new(live)) {
None => present_live(mtm, generation),
Some(displaced) => {
let then: AfterDismiss = Box::new(move |mtm| present_live(mtm, generation));
displaced.dismiss(mtm, Some(then));
}
}
}
fn present_live(mtm: MainThreadMarker, generation: u64) {
let Some(live) = take_live_as::<LiveSheet>(mtm, generation) else {
return;
};
let Some(presenter) = presenting_anchor(mtm) else {
live.finish(Err(PresentError::NoHost));
return;
};
presenter.presentViewController_animated_completion(&live.controller, true, None);
if live.controller.presentingViewController().is_none() {
log::warn!(
"frust-native-widgets: UIKit refused to present the sheet (the presenting controller \
is not in a window hierarchy)"
);
live.finish(Err(PresentError::NoHost));
return;
}
let scene = presenter
.view()
.and_then(|view| view.window())
.and_then(|window| window.windowScene());
if let Some(scene) = scene {
live.controller.observe_disconnect(&scene);
} else {
log::debug!(
"frust-native-widgets: the presenting controller has no window scene; the sheet's \
scene-disconnect HostLost is not observed"
);
}
park(mtm, generation, live);
}
fn park(mtm: MainThreadMarker, generation: u64, live: Box<LiveSheet>) {
if let Some(unexpected) = install_live(mtm, generation, live) {
unexpected.dismiss(mtm, None);
}
}
fn guarded(which: &str, body: impl FnOnce()) {
if catch_unwind(AssertUnwindSafe(body)).is_err() {
log::error!("frust-native-widgets: the sheet's {which} callback panicked");
}
}
fn resolve(generation: u64, outcome: Result<SheetOutcome, PresentError>) {
let Some(mtm) = MainThreadMarker::new() else {
log::error!("frust-native-widgets: a sheet callback arrived off the main thread");
return;
};
if let Some(live) = take_live_as::<LiveSheet>(mtm, generation) {
live.finish(outcome);
}
}
struct LiveSheet {
generation: u64,
tx: Sender<SheetOutcome>,
controller: Retained<FrustNativeSheetController>,
_resolvers: Vec<DetentResolver>,
custom_available: bool,
}
impl LiveSheet {
fn build(
mtm: MainThreadMarker,
spec: &SheetSpec,
tx: Sender<SheetOutcome>,
generation: u64,
) -> Self {
let custom_available = custom_detents_available();
let mut detents: Vec<Detent> = Vec::with_capacity(spec.detents.len());
for detent in &spec.detents {
let effective = effective_detent(*detent, custom_available);
if !detents.contains(&effective) {
detents.push(effective);
}
}
let controller = FrustNativeSheetController::new(
mtm,
SheetIvars {
generation,
dismissible: spec.dismissible,
action_ids: spec.content.actions.iter().map(|a| a.id.clone()).collect(),
detents: detents
.iter()
.map(|detent| (*detent, identifier(*detent).to_string()))
.collect(),
on_detent: spec.detent_listener(),
pending_dismiss: Cell::new(false),
},
);
controller.setView(Some(&content_view(mtm, spec, &controller)));
controller.setModalPresentationStyle(UIModalPresentationStyle::PageSheet);
if let Some(dark) = spec.dark {
controller.setOverrideUserInterfaceStyle(if dark {
UIUserInterfaceStyle::Dark
} else {
UIUserInterfaceStyle::Light
});
}
let mut resolvers = Vec::new();
if let Some(sheet) = controller.sheetPresentationController() {
configure_sheet(
mtm,
&sheet,
&controller,
spec,
&detents,
custom_available,
&mut resolvers,
);
} else {
log::warn!(
"frust-native-widgets: the sheet controller has no sheetPresentationController; \
presenting a plain page sheet without detents"
);
}
Self {
generation,
tx,
controller,
_resolvers: resolvers,
custom_available,
}
}
fn select(&self, detent: Detent) {
let detent = effective_detent(detent, self.custom_available);
let Some((_, name)) = self
.controller
.ivars()
.detents
.iter()
.find(|(known, _)| *known == detent)
else {
log::warn!(
"frust-native-widgets: select_detent({detent:?}) ignored: not one of this sheet's \
detents"
);
return;
};
let Some(sheet) = self.controller.sheetPresentationController() else {
return;
};
let id = NSString::from_str(name);
let target = sheet.clone();
let change = RcBlock::new(move || {
guarded("detent animation", || {
target.setSelectedDetentIdentifier(Some(&id));
});
});
sheet.animateChanges(&change);
}
fn finish(self, outcome: Result<SheetOutcome, PresentError>) {
let Self {
tx,
controller,
_resolvers,
..
} = self;
controller.stop_observing();
tx.send(outcome);
let _ = Retained::autorelease_ptr(controller);
}
fn close(
self: Box<Self>,
mtm: MainThreadMarker,
outcome: Result<SheetOutcome, PresentError>,
then: Option<AfterDismiss>,
) {
let presenter = self.controller.presentingViewController();
let Some(presenter) = presenter.filter(|_| !self.controller.isBeingDismissed()) else {
self.finish(outcome);
if let Some(then) = then {
then(mtm);
}
return;
};
let pending = Cell::new(Some((self, outcome, then)));
let completion = RcBlock::new(move || {
guarded("dismissal completion", || {
let Some((live, outcome, then)) = pending.take() else {
return;
};
live.finish(outcome);
if let (Some(then), Some(mtm)) = (then, MainThreadMarker::new()) {
then(mtm);
}
});
});
presenter.dismissViewControllerAnimated_completion(true, Some(&completion));
}
fn take_down(self: Box<Self>, mtm: MainThreadMarker, then: Option<AfterDismiss>) {
if displaced_action(self.controller.isBeingDismissed(), then.is_some())
== DisplacedAction::Repark
{
self.controller.ivars().pending_dismiss.set(true);
let generation = self.generation;
park(mtm, generation, self);
return;
}
self.close(
mtm,
Ok(SheetOutcome::Dismissed(DismissReason::Programmatic)),
then,
);
}
}
impl LivePresentation for LiveSheet {
fn dismiss(self: Box<Self>, mtm: MainThreadMarker, then: Option<AfterDismiss>) {
self.take_down(mtm, then);
}
}
fn configure_sheet(
mtm: MainThreadMarker,
sheet: &UISheetPresentationController,
controller: &FrustNativeSheetController,
spec: &SheetSpec,
detents: &[Detent],
custom_available: bool,
resolvers: &mut Vec<DetentResolver>,
) {
unsafe {
sheet.setDelegate(Some(ProtocolObject::from_ref(controller)));
}
let objects: Vec<Retained<UISheetPresentationControllerDetent>> = detents
.iter()
.map(|detent| detent_object(mtm, *detent, resolvers))
.collect();
sheet.setDetents(&NSArray::from_retained_slice(&objects));
if let Some(selected) = spec.selected {
let selected = effective_detent(selected, custom_available);
sheet.setSelectedDetentIdentifier(Some(&identifier(selected)));
}
if let Some(undimmed) = spec.largest_undimmed {
let undimmed = effective_detent(undimmed, custom_available);
sheet.setLargestUndimmedDetentIdentifier(Some(&identifier(undimmed)));
}
sheet.setPrefersGrabberVisible(spec.grabber);
sheet.setPrefersScrollingExpandsWhenScrolledToEdge(spec.scrolling_expands);
sheet.setPrefersEdgeAttachedInCompactHeight(true);
sheet.setWidthFollowsPreferredContentSizeWhenEdgeAttached(true);
if let Some(radius) = spec.corner_radius {
sheet.setPreferredCornerRadius(radius);
}
}
fn content_view(
mtm: MainThreadMarker,
spec: &SheetSpec,
target: &FrustNativeSheetController,
) -> Retained<UIView> {
let root = UIView::new(mtm);
root.setBackgroundColor(Some(&UIColor::systemBackgroundColor()));
if let Some(tint) = spec.tint {
unsafe { root.setTintColor(Some(&ui_color(tint as i32))) };
}
let stack = UIStackView::new(mtm);
stack.setAxis(UILayoutConstraintAxis::Vertical);
stack.setAlignment(UIStackViewAlignment::Fill);
stack.setSpacing(ROW_SPACING);
stack.setTranslatesAutoresizingMaskIntoConstraints(false);
root.addSubview(&stack);
let mut constraints: Vec<Retained<NSLayoutConstraint>> = Vec::new();
let content = &spec.content;
if let Some(title) = content.title.as_deref().filter(|t| !t.is_empty()) {
let label = wrapping_label(mtm, title);
set_label_font(&label, &UIFont::boldSystemFontOfSize(22.0));
stack.addArrangedSubview(&label);
}
if let Some(message) = content.message.as_deref().filter(|m| !m.is_empty()) {
let label = wrapping_label(mtm, message);
set_label_font(&label, &UIFont::systemFontOfSize(17.0));
set_label_text_color(&label, &UIColor::secondaryLabelColor());
stack.addArrangedSubview(&label);
}
if let Some(bytes) = content.image.as_deref() {
match UIImage::imageWithData(&NSData::with_bytes(bytes)) {
Some(image) => {
let view = UIImageView::initWithImage(UIImageView::alloc(mtm), Some(&image));
view.setContentMode(UIViewContentMode::ScaleAspectFit);
view.setClipsToBounds(true);
constraints.push(
view.heightAnchor()
.constraintLessThanOrEqualToConstant(IMAGE_MAX_HEIGHT),
);
stack.addArrangedSubview(&view);
}
None => log::warn!(
"frust-native-widgets: the sheet's image bytes did not decode; the image row is \
omitted"
),
}
}
for (index, action) in content.actions.iter().enumerate() {
let button = UIButton::buttonWithType(UIButtonType::System, mtm);
button.setTitle_forState(
Some(&NSString::from_str(&action.label)),
UIControlState::Normal,
);
button.setTag(index as isize);
match action.role {
ActionRole::Default => {}
ActionRole::Destructive => button
.setTitleColor_forState(Some(&UIColor::systemRedColor()), UIControlState::Normal),
ActionRole::Cancel => button.setTitleColor_forState(
Some(&UIColor::secondaryLabelColor()),
UIControlState::Normal,
),
}
if let Some(label) = button.titleLabel() {
set_label_font(&label, &UIFont::boldSystemFontOfSize(17.0));
}
let receiver: &AnyObject = target;
unsafe {
button.addTarget_action_forControlEvents(
Some(receiver),
sel!(frustSheetAction:),
UIControlEvents::TouchUpInside,
);
}
constraints.push(
button
.heightAnchor()
.constraintGreaterThanOrEqualToConstant(ACTION_MIN_HEIGHT),
);
stack.addArrangedSubview(&button);
}
let guide = root.safeAreaLayoutGuide();
constraints.extend([
stack
.topAnchor()
.constraintEqualToAnchor_constant(&guide.topAnchor(), TOP_INSET),
stack
.leadingAnchor()
.constraintEqualToAnchor_constant(&guide.leadingAnchor(), SIDE_INSET),
guide
.trailingAnchor()
.constraintEqualToAnchor_constant(&stack.trailingAnchor(), SIDE_INSET),
guide
.bottomAnchor()
.constraintGreaterThanOrEqualToAnchor_constant(&stack.bottomAnchor(), BOTTOM_INSET),
]);
NSLayoutConstraint::activateConstraints(&NSArray::from_retained_slice(&constraints), mtm);
root
}
fn wrapping_label(mtm: MainThreadMarker, text: &str) -> Retained<UILabel> {
let label = UILabel::new(mtm);
label.setText(Some(&NSString::from_str(text)));
label.setNumberOfLines(0);
label
}
struct SheetIvars {
generation: u64,
dismissible: bool,
action_ids: Vec<String>,
detents: Vec<(Detent, String)>,
on_detent: Option<Arc<dyn Fn(Detent) + Send + Sync>>,
pending_dismiss: Cell<bool>,
}
define_class!(
#[unsafe(super(UIViewController, UIResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = SheetIvars]
struct FrustNativeSheetController;
unsafe impl NSObjectProtocol for FrustNativeSheetController {}
unsafe impl UIAdaptivePresentationControllerDelegate for FrustNativeSheetController {
#[unsafe(method(presentationControllerShouldDismiss:))]
fn presentation_controller_should_dismiss(
&self,
_controller: &UIPresentationController,
) -> bool {
self.ivars().dismissible
}
#[unsafe(method(presentationControllerDidDismiss:))]
fn presentation_controller_did_dismiss(&self, _controller: &UIPresentationController) {
let generation = self.ivars().generation;
guarded("swipe-down", || {
resolve(generation, Ok(SheetOutcome::Dismissed(DismissReason::User)));
});
}
}
unsafe impl UISheetPresentationControllerDelegate for FrustNativeSheetController {
#[unsafe(method(sheetPresentationControllerDidChangeSelectedDetentIdentifier:))]
fn sheet_did_change_detent(&self, sheet: &UISheetPresentationController) {
guarded("detent change", || {
let Some(selected) = sheet.selectedDetentIdentifier() else {
return;
};
let selected = selected.to_string();
let ivars = self.ivars();
let detent = ivars
.detents
.iter()
.find(|(_, name)| *name == selected)
.map(|(detent, _)| *detent);
if let (Some(detent), Some(listener)) = (detent, ivars.on_detent.as_ref()) {
listener(detent);
}
});
}
}
impl FrustNativeSheetController {
#[unsafe(method(frustSheetAction:))]
fn sheet_action(&self, sender: &UIButton) {
let generation = self.ivars().generation;
let id = usize::try_from(sender.tag())
.ok()
.and_then(|index| self.ivars().action_ids.get(index))
.cloned();
guarded("action", || {
let Some(id) = id else {
log::error!("frust-native-widgets: a sheet action fired with an unknown tag");
return;
};
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if let Some(live) = take_live_as::<LiveSheet>(mtm, generation) {
live.close(mtm, Ok(SheetOutcome::Action(id)), None);
}
});
}
#[unsafe(method(viewDidDisappear:))]
fn view_did_disappear(&self, animated: bool) {
let _: () = unsafe { msg_send![super(self), viewDidDisappear: animated] };
let generation = self.ivars().generation;
guarded("disappearance", || {
on_main(move |mtm| {
let Some(live) = take_live_as::<LiveSheet>(mtm, generation) else {
return;
};
if live.controller.presentingViewController().is_some() {
park(mtm, generation, live);
} else {
live.finish(Ok(SheetOutcome::HostLost));
}
});
});
}
#[unsafe(method(sceneDidDisconnect:))]
fn scene_did_disconnect(&self, _notification: &NSNotification) {
let generation = self.ivars().generation;
guarded("scene disconnect", || {
resolve(generation, Ok(SheetOutcome::HostLost));
});
}
#[unsafe(method(viewDidAppear:))]
fn view_did_appear(&self, animated: bool) {
let _: () = unsafe { msg_send![super(self), viewDidAppear: animated] };
if !self.ivars().pending_dismiss.replace(false) {
return;
}
let generation = self.ivars().generation;
guarded("pending dismiss retry", || {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if let Some(live) = take_live_as::<LiveSheet>(mtm, generation) {
live.take_down(mtm, None);
}
});
}
}
);
impl FrustNativeSheetController {
fn new(mtm: MainThreadMarker, ivars: SheetIvars) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(ivars);
unsafe { msg_send![super(this), init] }
}
fn observe_disconnect(&self, scene: &AnyObject) {
let center = NSNotificationCenter::defaultCenter();
let name = unsafe { UISceneDidDisconnectNotification };
unsafe {
center.addObserver_selector_name_object(
self,
sel!(sceneDidDisconnect:),
Some(name),
Some(scene),
);
}
}
fn stop_observing(&self) {
unsafe { NSNotificationCenter::defaultCenter().removeObserver(self) };
}
}