diff --git a/docs/host-ios.md b/docs/host-ios.md index 3f2afbc..3bf5c58 100644 --- a/docs/host-ios.md +++ b/docs/host-ios.md @@ -32,7 +32,9 @@ backend separately as `wd.dpi = 96 * screen.scale`. present). *Left, right and bottom are reported but not subtracted — do that yourself.* - **Dynamic Type** — `metrics->ui_scale` and every `NEUI_METRIC_*` are already scaled, and an explicit `NEUI_ATTR_FONT_SIZE` is routed through `UIFontMetrics`. **Do not scale again.** -- **`NEUI_EVENT_METRICS_CHANGED`** on Dynamic Type, rotation and safe-area change. +- **`NEUI_EVENT_METRICS_CHANGED`** on Dynamic Type, rotation and safe-area change — a change + confined to the left, right or bottom inset moves no bounds and raises no `RESIZE`, so this + is the only notice a client laying out inside the safe area gets. - **Dark mode** via `NEUI_ATTR_FOLLOW_SYSTEM_THEME`; `@2x`/`@3x` asset selection. ### Not available here diff --git a/hosts/crossplatform/platform_ios.mm b/hosts/crossplatform/platform_ios.mm index b4664fd..88b029e 100644 --- a/hosts/crossplatform/platform_ios.mm +++ b/hosts/crossplatform/platform_ios.mm @@ -1750,6 +1750,13 @@ @interface NEUIViewController : UIViewController // still re-fires RESIZE - the usable client rect changed even though the // frame size didn't, so the client must re-lay-out below the new inset. int _last_top_inset; + // The other three safe-area edges. They are not folded into the client rect, + // so they raise METRICS_CHANGED on their own rather than RESIZE - but they do + // move, and a client laying out inside the safe area has no other way to hear + // about it. + int _last_left; + int _last_right; + int _last_bottom; } @end @@ -1836,25 +1843,54 @@ - (void)reportResizeIfChanged // The effective top inset (status-bar/notch safe area + hamburger band when // a menubar exists) feeds widget_client_rect; track it alongside the bounds. int top_inset = session->frame_top_inset(widget_index); - if (w == _last_w && h == _last_h && top_inset == _last_top_inset) return; + // And the other three edges, read off the same view metrics_safe_area_xpl_ios + // reads so the gate and the answer cannot drift apart. Gating on the top alone + // swallowed a side- or bottom-only resolve entirely: the client was never + // told, and went on laying out to insets that had moved. In landscape those + // are the edges that matter - the rounded corners and the camera housing down + // the sides, the home indicator along the bottom. + int left = 0, right = 0, bottom = 0; + if (@available(iOS 11.0, *)) { + NEUIView* view = [self neuiView]; + if (view) { + UIEdgeInsets ins = view.safeAreaInsets; + left = (int)(ins.left + 0.5); + right = (int)(ins.right + 0.5); + bottom = (int)(ins.bottom + 0.5); + } + } + // The top inset is folded into the rect get_client_rect reports, so a change + // to it is a resize as far as a client is concerned even when the bounds held + // still. The other three are not, so they raise METRICS_CHANGED alone. + const bool client_rect_changed = + (w != _last_w || h != _last_h || top_inset != _last_top_inset); + const bool edges_changed = + (left != _last_left || right != _last_right || bottom != _last_bottom); + if (!client_rect_changed && !edges_changed) return; _last_w = w; _last_h = h; _last_top_inset = top_inset; + _last_left = left; + _last_right = right; + _last_bottom = bottom; auto& wd = session->_widgets[widget_index]; - wd.width = w; - wd.height = h; - session->resize_render_ctx(widget_index, (uint32_t)w, (uint32_t)h); + if (client_rect_changed) { + wd.width = w; + wd.height = h; + session->resize_render_ctx(widget_index, (uint32_t)w, (uint32_t)h); - neui_event_t ev = {}; - ev.type = NEUI_EVENT_RESIZE; - ev.data.resize.widget = { wd.widget_id }; - ev.data.resize.width = w; - ev.data.resize.height = h; - session->dispatch_event(&ev); + neui_event_t ev = {}; + ev.type = NEUI_EVENT_RESIZE; + ev.data.resize.widget = { wd.widget_id }; + ev.data.resize.width = w; + ev.data.resize.height = h; + session->dispatch_event(&ev); + } // The safe-area insets (and thus safe_area_insets / get_client_rect) change on - // rotation + when the notch/status-bar inset first resolves, so notify the - // client that the metrics changed too (alongside RESIZE). + // rotation, when the notch/status-bar inset first resolves, and when any of + // the other three edges moves. This fires for all of them - alongside RESIZE + // where the client rect moved too, on its own where only an edge did. dispatch_metrics_changed_xpl_ios(session, widget_index); // A rotation settles here too. Told from the layout pass rather than from // UIDevice orientation notifications: no accelerometer, and this is the @@ -1904,8 +1940,9 @@ - (void)viewDidLayoutSubviews // A safe-area change (status bar appearing, the first inset resolve after the // window binds to its scene, or rotation revealing a notch on a new edge) does // not change the view bounds, so reportResizeIfChanged would otherwise stay -// silent. The inset feeds widget_client_rect + the hamburger band position, so -// re-fire RESIZE (now inset-aware) and re-layout the button. +// silent. The top inset feeds widget_client_rect + the hamburger band position, +// so re-fire RESIZE for that one; a move on any of the other three edges raises +// METRICS_CHANGED instead. Either way the button is re-laid-out. - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; diff --git a/hosts/ios/window.mm b/hosts/ios/window.mm index dbccb58..9129ad6 100644 --- a/hosts/ios/window.mm +++ b/hosts/ios/window.mm @@ -1546,6 +1546,11 @@ @interface NEUINativeIOSViewController : UIViewController uint32_t widget_index; @private int _last_w, _last_h, _last_inset; + // The other three safe-area edges. They do not move the client rect the way + // the top inset does, so they drive METRICS_CHANGED on their own rather than + // RESIZE - but they do move, and a client laying out inside the safe area has + // no other way to hear about it. + int _last_left, _last_right, _last_bottom; } @end @@ -1628,21 +1633,47 @@ - (void)reportResizeIfChanged int w = (int)sz.width, h = (int)sz.height; if (w <= 0 || h <= 0) return; int inset = ios_host::frame_top_inset_ios(session, widget_index); - if (w == _last_w && h == _last_h && inset == _last_inset) return; + // And the other three edges, read off the same view metrics_safe_area_ios + // reads so the gate and the answer cannot drift apart. Gating on the top + // alone swallowed a side- or bottom-only resolve entirely: the client was + // never told, and went on laying out to insets that had moved. In landscape + // those are the edges that matter - the rounded corners and the camera + // housing down the sides, the home indicator along the bottom. + int left = 0, right = 0, bottom = 0; + if (@available(iOS 11.0, *)) { + NEUINativeIOSContentView* cv = [self contentView]; + if (cv) { + UIEdgeInsets ins = cv.safeAreaInsets; + left = (int)(ins.left + 0.5); + right = (int)(ins.right + 0.5); + bottom = (int)(ins.bottom + 0.5); + } + } + // The top inset is folded into the rect get_client_rect reports, so a change + // to it is a resize as far as a client is concerned even when the bounds held + // still. The other three are not, so they raise METRICS_CHANGED alone. + const bool client_rect_changed = (w != _last_w || h != _last_h || inset != _last_inset); + const bool edges_changed = + (left != _last_left || right != _last_right || bottom != _last_bottom); + if (!client_rect_changed && !edges_changed) return; _last_w = w; _last_h = h; _last_inset = inset; + _last_left = left; _last_right = right; _last_bottom = bottom; auto& wd = session->_widgets[widget_index]; - wd.width = w; wd.height = h; - auto* backend = neui_cg_backend::get_backend(); - if (backend && backend->resize && wd.render_ctx) - backend->resize(wd.render_ctx, (uint32_t)w, (uint32_t)h); - neui_event_t ev = {}; - ev.type = NEUI_EVENT_RESIZE; - ev.data.resize.widget = { wd.widget_id }; - ev.data.resize.width = w; ev.data.resize.height = h; - session->dispatch_event(&ev); + if (client_rect_changed) { + wd.width = w; wd.height = h; + auto* backend = neui_cg_backend::get_backend(); + if (backend && backend->resize && wd.render_ctx) + backend->resize(wd.render_ctx, (uint32_t)w, (uint32_t)h); + neui_event_t ev = {}; + ev.type = NEUI_EVENT_RESIZE; + ev.data.resize.widget = { wd.widget_id }; + ev.data.resize.width = w; ev.data.resize.height = h; + session->dispatch_event(&ev); + } // The safe-area insets (and thus safe_area_insets / get_client_rect) change on - // rotation + when the notch/status-bar inset first resolves, so notify the - // client that the metrics changed too (alongside RESIZE). + // rotation, when the notch/status-bar inset first resolves, and when any of + // the other three edges moves. This fires for all of them - alongside RESIZE + // where the client rect moved too, on its own where only an edge did. ios_host::dispatch_metrics_changed_ios(session, widget_index); // A rotation settles here too. Told from the layout pass rather than from // UIDevice orientation notifications: no accelerometer, and this is the