| 1 | // Copyright (C) 2024 The Qt Company Ltd. |
| 2 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only |
| 3 | |
| 4 | #include <QtQuick/private/qquicksafearea_p.h> |
| 5 | |
| 6 | #include <QtQuick/private/qquickanchors_p_p.h> |
| 7 | #include <QtQuick/private/qquickitem_p.h> |
| 8 | #include <QtQuick/private/qquickflickable_p.h> |
| 9 | #include <QtQuick/qquickwindow.h> |
| 10 | #include <QtQuick/qquickitem.h> |
| 11 | |
| 12 | QT_BEGIN_NAMESPACE |
| 13 | |
| 14 | Q_STATIC_LOGGING_CATEGORY(lcSafeArea, "qt.quick.safearea" , QtWarningMsg) |
| 15 | |
| 16 | /*! |
| 17 | \qmltype SafeArea |
| 18 | \nativetype QQuickSafeArea |
| 19 | \inqmlmodule QtQuick |
| 20 | \ingroup qtquick-visual |
| 21 | \since 6.9 |
| 22 | \brief Provides access to the safe area properties of the item or window. |
| 23 | |
| 24 | The SafeArea attached type provides information about the areas of |
| 25 | an Item or Window where content may risk being overlapped by other |
| 26 | UI elements, such as system title bars or status bars. |
| 27 | |
| 28 | This information can be used to lay out children of an item within |
| 29 | the safe area of the item, while still allowing a background color |
| 30 | or effect to span the entire item. |
| 31 | |
| 32 | \table |
| 33 | \row |
| 34 | \li \snippet qml/safearea/basic.qml 0 |
| 35 | \li \inlineimage safearea-ios.webp |
| 36 | \endtable |
| 37 | |
| 38 | The SafeArea margins are relative to the item they attach to. If an |
| 39 | ancestor item has laid out its children within the safe area margins, |
| 40 | any descendant item with its own SafeArea attached will report zero |
| 41 | margins, unless \l{Additional margins}{additional margins} have been |
| 42 | added. |
| 43 | |
| 44 | \note An item should not be positioned based on \e{its own} safe area, |
| 45 | as that would result in a binding loop. |
| 46 | |
| 47 | \section2 Additional margins |
| 48 | |
| 49 | Sometimes an item's layout involves child items that overlap each other, |
| 50 | for example in a window with a semi transparent header, where the rest |
| 51 | of the window content flows underneath the header. |
| 52 | |
| 53 | In this scenario, the item may reflect the header's position and size |
| 54 | to the child items via the additionalMargins property. |
| 55 | |
| 56 | The additional margins will be added to any margins that the |
| 57 | item already picks up from its parent hierarchy (including system |
| 58 | margins, such as title bars or status bars), and child items will |
| 59 | reflect the combined margins accordingly. |
| 60 | |
| 61 | \table |
| 62 | \row |
| 63 | \li \snippet qml/safearea/additional.qml 0 |
| 64 | \li \br \inlineimage safearea-ios-header.webp |
| 65 | \endtable |
| 66 | |
| 67 | In the example above, the header item is positioned at the top of |
| 68 | the window, which may potentially overlap with existing safe area |
| 69 | margins coming from the window. To account for this we only add |
| 70 | additional margins for the part of the header that extends beyond |
| 71 | the window's safe area margins. |
| 72 | |
| 73 | \note In this example the header item does not overlap the child item, |
| 74 | as the goal is to show how the items are positioned and resized in |
| 75 | response to safe area margin changes. |
| 76 | |
| 77 | \section2 Controls |
| 78 | |
| 79 | Applying safe area margins to a Control is straightforward, |
| 80 | as Control already offers properties to add padding to the |
| 81 | control's content item. |
| 82 | |
| 83 | \snippet qml/safearea/controls.qml 0 |
| 84 | */ |
| 85 | |
| 86 | QQuickSafeArea *QQuickSafeArea::qmlAttachedProperties(QObject *attachee) |
| 87 | { |
| 88 | auto *item = qobject_cast<QQuickItem*>(o: attachee); |
| 89 | if (!item) { |
| 90 | if (auto *window = qobject_cast<QQuickWindow*>(object: attachee)) |
| 91 | item = window->contentItem(); |
| 92 | } |
| 93 | if (!item) { |
| 94 | if (auto *safeAreaAttachable = qobject_cast<QQuickSafeAreaAttachable*>(object: attachee)) |
| 95 | item = safeAreaAttachable->safeAreaAttachmentItem(); |
| 96 | } |
| 97 | if (!item) { |
| 98 | qmlWarning(me: attachee) << "SafeArea can not be attached to this type" ; |
| 99 | return nullptr; |
| 100 | } |
| 101 | |
| 102 | // We may already have created a safe area for Window, and are now |
| 103 | // requesting one for Window.contentItem (or the other way around). |
| 104 | // As both map to the same safe area item, we need to check first |
| 105 | // if we already have created one for this item. |
| 106 | if (auto *safeArea = item->findChild<QQuickSafeArea*>(options: Qt::FindDirectChildrenOnly)) |
| 107 | return safeArea; |
| 108 | |
| 109 | return new QQuickSafeArea(item); |
| 110 | } |
| 111 | |
| 112 | QQuickSafeArea::QQuickSafeArea(QQuickItem *item) |
| 113 | : QObject(item) |
| 114 | { |
| 115 | qCInfo(lcSafeArea) << "Creating" << this; |
| 116 | |
| 117 | connect(sender: item, signal: &QQuickItem::windowChanged, |
| 118 | context: this, slot: &QQuickSafeArea::windowChanged); |
| 119 | |
| 120 | item->setFlag(flag: QQuickItem::ItemObservesViewport); |
| 121 | QQuickItemPrivate::get(item)->addItemChangeListener( |
| 122 | listener: this, types: QQuickItemPrivate::Matrix); |
| 123 | |
| 124 | updateSafeArea(); |
| 125 | } |
| 126 | |
| 127 | QQuickSafeArea::~QQuickSafeArea() |
| 128 | { |
| 129 | qCInfo(lcSafeArea) << "Destroying" << this; |
| 130 | |
| 131 | const auto listenedItems = m_listenedItems; |
| 132 | for (const auto &item : listenedItems) { |
| 133 | if (!item) |
| 134 | continue; |
| 135 | auto *itemPrivate = QQuickItemPrivate::get(item); |
| 136 | itemPrivate->removeItemChangeListener(this, |
| 137 | types: QQuickItemPrivate::Matrix); |
| 138 | itemPrivate->removeItemChangeListener(this, |
| 139 | types: QQuickItemPrivate::Geometry); |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /*! |
| 144 | \qmlpropertygroup QtQuick::SafeArea::margins |
| 145 | \qmlproperty real QtQuick::SafeArea::margins.top |
| 146 | \qmlproperty real QtQuick::SafeArea::margins.left |
| 147 | \qmlproperty real QtQuick::SafeArea::margins.right |
| 148 | \qmlproperty real QtQuick::SafeArea::margins.bottom |
| 149 | \readonly |
| 150 | |
| 151 | This property holds the safe area margins, relative |
| 152 | to the attached item. |
| 153 | |
| 154 | \sa additionalMargins |
| 155 | */ |
| 156 | QMarginsF QQuickSafeArea::margins() const |
| 157 | { |
| 158 | return m_safeAreaMargins; |
| 159 | } |
| 160 | |
| 161 | /*! |
| 162 | \qmlpropertygroup QtQuick::SafeArea::additionalMargins |
| 163 | \qmlproperty real QtQuick::SafeArea::additionalMargins.top |
| 164 | \qmlproperty real QtQuick::SafeArea::additionalMargins.left |
| 165 | \qmlproperty real QtQuick::SafeArea::additionalMargins.right |
| 166 | \qmlproperty real QtQuick::SafeArea::additionalMargins.bottom |
| 167 | |
| 168 | This property holds the additional safe area margins for the item. |
| 169 | |
| 170 | The additional safe area margins can not be negative, and will be |
| 171 | automatically clamped to 0. |
| 172 | |
| 173 | The resulting safe area margins of the item are the sum of the inherited |
| 174 | margins (for example from title bars or status bar) and the additional |
| 175 | margins applied to the item. |
| 176 | |
| 177 | \sa margins |
| 178 | */ |
| 179 | |
| 180 | void QQuickSafeArea::setAdditionalMargins(const QMarginsF &additionalMargins) |
| 181 | { |
| 182 | // Additional margins should never be negative |
| 183 | auto newMargins = additionalMargins | QMarginsF(); |
| 184 | |
| 185 | if (newMargins == m_additionalMargins) |
| 186 | return; |
| 187 | |
| 188 | m_additionalMargins = newMargins; |
| 189 | |
| 190 | emit additionalMarginsChanged(); |
| 191 | |
| 192 | auto *attachedItem = qobject_cast<QQuickItem*>(o: parent()); |
| 193 | updateSafeAreasRecursively(fromItem: attachedItem); |
| 194 | } |
| 195 | |
| 196 | QMarginsF QQuickSafeArea::additionalMargins() const |
| 197 | { |
| 198 | return m_additionalMargins; |
| 199 | } |
| 200 | |
| 201 | /* |
| 202 | Maps the safe area \a margins from \a fromItem to \a toItem |
| 203 | */ |
| 204 | static QMarginsF toLocalMargins(const QMarginsF &margins, QQuickItem *fromItem, QQuickItem *toItem) |
| 205 | { |
| 206 | if (margins.isNull()) |
| 207 | return margins; |
| 208 | |
| 209 | const auto localMarginRect = fromItem->mapRectToItem(item: toItem, |
| 210 | rect: QRectF(margins.left(), margins.top(), |
| 211 | fromItem->width() - margins.left() - margins.right(), |
| 212 | fromItem->height() - margins.top() - margins.bottom())); |
| 213 | |
| 214 | // Only return a mapped margin if there was an original margin |
| 215 | return QMarginsF( |
| 216 | margins.left() > 0 ? localMarginRect.left() : 0, |
| 217 | margins.top() > 0 ? localMarginRect.top() : 0, |
| 218 | margins.right() > 0 ? toItem->width() - localMarginRect.right() : 0, |
| 219 | margins.bottom() > 0 ? toItem->height() - localMarginRect.bottom() : 0 |
| 220 | ) | QMarginsF(); |
| 221 | } |
| 222 | |
| 223 | void QQuickSafeArea::updateSafeArea() |
| 224 | { |
| 225 | qCDebug(lcSafeArea) << "✨ Updating" << this; |
| 226 | |
| 227 | auto *attachedItem = qobject_cast<QQuickItem*>(o: parent()); |
| 228 | if (!QQuickItemPrivate::get(item: attachedItem)->componentComplete) { |
| 229 | qCDebug(lcSafeArea) << attachedItem << "is not complete. Deferring" ; |
| 230 | return; |
| 231 | } |
| 232 | |
| 233 | QMarginsF inheritedMargins; |
| 234 | auto *parentItem = attachedItem->parentItem(); |
| 235 | while (parentItem) { |
| 236 | if (qobject_cast<QQuickFlickable*>(object: parentItem)) { |
| 237 | // Stop propagation of safe areas when we hit a Flickable, |
| 238 | // as items within the content item that account for safe |
| 239 | // area margins will continuously update when the content |
| 240 | // item is moved, which is not necessarily what the user |
| 241 | // expects. |
| 242 | qCDebug(lcSafeArea) << "Stopping safe area margin propagation on" << parentItem; |
| 243 | break; |
| 244 | } |
| 245 | |
| 246 | |
| 247 | // We attach the safe area to the relevant item for an attachee |
| 248 | // such as QQuickWindow or QQuickPopup, so we can't go via |
| 249 | // qmlAttachedPropertiesObject to find the safe area for an |
| 250 | // item, as the attached object cache is based on the original |
| 251 | // attachee. |
| 252 | if (auto *safeArea = parentItem->findChild<QQuickSafeArea*>(options: Qt::FindDirectChildrenOnly)) { |
| 253 | inheritedMargins = safeArea->margins(); |
| 254 | break; |
| 255 | } |
| 256 | |
| 257 | parentItem = parentItem->parentItem(); |
| 258 | } |
| 259 | |
| 260 | const auto *window = attachedItem->window(); |
| 261 | if (!parentItem && window) { |
| 262 | // We didn't find a parent item with a safe area, |
| 263 | // so inherit the margins from the window. |
| 264 | parentItem = window->contentItem(); |
| 265 | inheritedMargins = window->safeAreaMargins(); |
| 266 | } |
| 267 | |
| 268 | auto inheritedMarginsMapped = toLocalMargins(margins: inheritedMargins, fromItem: parentItem, toItem: attachedItem); |
| 269 | |
| 270 | // Make sure margins are never negative |
| 271 | const QMarginsF newMargins = QMarginsF() | (inheritedMarginsMapped + additionalMargins()); |
| 272 | |
| 273 | if (newMargins != m_safeAreaMargins) { |
| 274 | qCDebug(lcSafeArea) << "Margins changed from" << m_safeAreaMargins |
| 275 | << "to" << newMargins |
| 276 | << "based on inherited" << inheritedMargins |
| 277 | << "mapped to local" << inheritedMarginsMapped |
| 278 | << "and additional" << additionalMargins(); |
| 279 | |
| 280 | m_safeAreaMargins = newMargins; |
| 281 | |
| 282 | if (emittingMarginsUpdate) { |
| 283 | // We are already in the process of emitting an update for this |
| 284 | // safe area, which resulted in the safe area margins changing. |
| 285 | // This can be a binding loop if the margins do not stabilize, |
| 286 | // which we'll detect when we return from the root emit below. |
| 287 | qCDebug(lcSafeArea) << "Already emitting update for" << this; |
| 288 | return; |
| 289 | } |
| 290 | |
| 291 | QScopedValueRollback blocker(emittingMarginsUpdate, true); |
| 292 | emit marginsChanged(); |
| 293 | |
| 294 | if (m_safeAreaMargins != newMargins) { |
| 295 | qCDebug(lcSafeArea) << "⚠️ Possible binding loop for" << this |
| 296 | << newMargins << "changed to" << m_safeAreaMargins; |
| 297 | |
| 298 | QScopedValueRollback blocker(detectedPossibleBindingLoop, true); |
| 299 | |
| 300 | for (int i = 0; i < 5; ++i) { |
| 301 | auto marginsBeforeEmit = m_safeAreaMargins; |
| 302 | emit marginsChanged(); |
| 303 | if (m_safeAreaMargins == marginsBeforeEmit) { |
| 304 | qCDebug(lcSafeArea) << "✅ Margins stabilized for" << this; |
| 305 | return; |
| 306 | } |
| 307 | |
| 308 | qCDebug(lcSafeArea) << qPrintable(QStringLiteral("‼️" ).repeated(i + 1)) |
| 309 | << marginsBeforeEmit << "changed to" << m_safeAreaMargins; |
| 310 | } |
| 311 | |
| 312 | qmlWarning(me: attachedItem) << "Safe area binding loop detected" ; |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | void QQuickSafeArea::windowChanged() |
| 318 | { |
| 319 | updateSafeArea(); |
| 320 | } |
| 321 | |
| 322 | void QQuickSafeArea::itemTransformChanged(QQuickItem *item, QQuickItem *transformedItem) |
| 323 | { |
| 324 | Q_ASSERT(item == parent()); |
| 325 | |
| 326 | auto *transformedItemPrivate = QQuickItemPrivate::get(item: transformedItem); |
| 327 | qCDebug(lcSafeArea) << "📏 Transform changed for" << transformedItem |
| 328 | << "with dirty state" << transformedItemPrivate->dirtyToString(); |
| 329 | |
| 330 | if (qobject_cast<QQuickFlickable*>(object: transformedItem->parentItem())) { |
| 331 | qCDebug(lcSafeArea) << "Ignoring transform change for Flickable content item" ; |
| 332 | return; |
| 333 | } |
| 334 | |
| 335 | // The order of transform and geometry change callbacks may not be in paint order, |
| 336 | // so to ensure we update the safe areas in paint order we find the item closest |
| 337 | // to the transformed item with a safe area, and let that safe area trigger the |
| 338 | // update recursively in paint order. |
| 339 | if (transformedItem != item) { |
| 340 | for (auto *parent = item->parentItem(); parent; parent = parent->parentItem()) { |
| 341 | if (parent->findChild<QQuickSafeArea*>(options: Qt::FindDirectChildrenOnly)) |
| 342 | item = parent; |
| 343 | |
| 344 | if (parent == transformedItem) |
| 345 | break; |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | if (item != parent()) { |
| 350 | qCDebug(lcSafeArea) << "Found" << item << "closer to transformed item than" << this; |
| 351 | return; |
| 352 | } |
| 353 | |
| 354 | // The dirtying of position and size will be followed by a geometry change, |
| 355 | // which via anchors or event listeners may result in an ancestor invalidating |
| 356 | // its transform, which might invalidate the margins we're about to compute. |
| 357 | // Instead of processing the margin change now, possibly resulting in a flip- |
| 358 | // flop of the margins, we wait for the geometry notification, where the item |
| 359 | // hierarchy has already reacted to the geometry change of the transformed item. |
| 360 | // This accounts for anchors, and items that listen to geometry changes, but not |
| 361 | // property bindings, as those are emitted after notifying listeners (us) about |
| 362 | // the geometry change. |
| 363 | auto dirtyAttributes = transformedItemPrivate->dirtyAttributes; |
| 364 | if (dirtyAttributes & (QQuickItemPrivate::Position | QQuickItemPrivate::Size)) { |
| 365 | qCDebug(lcSafeArea) << "Deferring update of" << this << "until geometry change" ; |
| 366 | transformedItemPrivate->addItemChangeListener( |
| 367 | listener: this, types: QQuickItemPrivate::Geometry); |
| 368 | return; |
| 369 | } |
| 370 | |
| 371 | updateSafeAreasRecursively(fromItem: item); |
| 372 | } |
| 373 | |
| 374 | void QQuickSafeArea::itemGeometryChanged(QQuickItem *item, QQuickGeometryChange change, const QRectF &oldGeometry) |
| 375 | { |
| 376 | Q_UNUSED(change); |
| 377 | Q_UNUSED(oldGeometry); |
| 378 | |
| 379 | auto *itemPrivate = QQuickItemPrivate::get(item); |
| 380 | itemPrivate->removeItemChangeListener(this, types: QQuickItemPrivate::Geometry); |
| 381 | |
| 382 | qCDebug(lcSafeArea) << "📐 Geometry changed for" << item << "from" << oldGeometry |
| 383 | << "to" << QRectF(item->position(), item->size()); |
| 384 | |
| 385 | updateSafeAreasRecursively(fromItem: item); |
| 386 | } |
| 387 | |
| 388 | void QQuickSafeArea::updateSafeAreasRecursively(QQuickItem *item) |
| 389 | { |
| 390 | Q_ASSERT(item); |
| 391 | |
| 392 | if (auto *safeArea = item->findChild<QQuickSafeArea*>(options: Qt::FindDirectChildrenOnly)) |
| 393 | safeArea->updateSafeArea(); |
| 394 | |
| 395 | auto *itemPrivate = QQuickItemPrivate::get(item); |
| 396 | const auto paintOrderChildItems = itemPrivate->paintOrderChildItems(); |
| 397 | for (auto *child : paintOrderChildItems) |
| 398 | updateSafeAreasRecursively(item: child); |
| 399 | } |
| 400 | |
| 401 | void QQuickSafeArea::addSourceItem(QQuickItem *item) |
| 402 | { |
| 403 | m_listenedItems << item; |
| 404 | } |
| 405 | |
| 406 | void QQuickSafeArea::removeSourceItem(QQuickItem *item) |
| 407 | { |
| 408 | m_listenedItems.removeAll(t: item); |
| 409 | } |
| 410 | |
| 411 | #ifndef QT_NO_DEBUG_STREAM |
| 412 | QDebug operator<<(QDebug debug, const QQuickSafeArea *safeArea) |
| 413 | { |
| 414 | QDebugStateSaver saver(debug); |
| 415 | debug.nospace(); |
| 416 | |
| 417 | if (!safeArea) { |
| 418 | debug << "QQuickSafeArea(nullptr)" ; |
| 419 | return debug; |
| 420 | } |
| 421 | |
| 422 | debug << safeArea->metaObject()->className() << '(' << static_cast<const void *>(safeArea); |
| 423 | |
| 424 | debug << ", attachedItem=" << safeArea->parent(); |
| 425 | debug << ", safeAreaMargins=" << safeArea->m_safeAreaMargins; |
| 426 | debug << ", additionalMargins=" << safeArea->additionalMargins(); |
| 427 | |
| 428 | debug << ')'; |
| 429 | return debug; |
| 430 | } |
| 431 | #endif // QT_NO_DEBUG_STREAM |
| 432 | |
| 433 | QQuickSafeAreaAttachable::~QQuickSafeAreaAttachable() = default; |
| 434 | |
| 435 | QT_END_NAMESPACE |
| 436 | |
| 437 | #include "moc_qquicksafearea_p.cpp" |
| 438 | |