Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions clay/ui/component/base_view.cc
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,15 @@ void BaseView::AddChild(BaseView* child, int index) {
if (attach_to_tree_) {
child->OnAttachToTree();
}

if (this == page_view_ &&
!child->direct_page_child_first_add_exposure_attempted_) {
child->direct_page_child_first_add_exposure_attempted_ = true;
if (page_view_->HasIntersectionObserverManager()) {
page_view_->intersection_observer_manager()
->TryNotifyDirectPageChildOnFirstAdd(child);
}
}
}

void BaseView::OnLayoutFinish(BaseView* view) {}
Expand Down
1 change: 1 addition & 0 deletions clay/ui/component/base_view.h
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ class BaseView : public TypeIdentifiable<BaseView>,
// FIXME(baiqiang): remove focus&text list then move to component
std::string item_key_;
bool attach_to_tree_ = false;
bool direct_page_child_first_add_exposure_attempted_ = false;
std::optional<bool> ignore_focus_;
BaseView* parent_ = nullptr;
PageView* page_view_ = nullptr;
Expand Down
10 changes: 10 additions & 0 deletions clay/ui/component/expose_manager/expose_observer.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ void ExposeObserver::SetExposureHostVisible(bool visible) {
exposure_host_visible_ = visible;
}

bool ExposeObserver::TryNotifyAppearWithoutGeometry() {
if (!available_ || !root_ || expose_attrs_.exposure_stoped ||
!exposure_host_visible_ ||
!expose_attrs_.exposure_should_notify_appear_ || IsExposed()) {
return false;
}
NotifyExposureEvent(true);
return true;
}

void ExposeObserver::NotifyAppearEvent(bool appear) {
if (attached_view_ && attached_view_->page_view()) {
const char* event_name = appear ? "uiappear" : "uidisappear";
Expand Down
13 changes: 13 additions & 0 deletions clay/ui/component/expose_manager/expose_observer.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ class ExposeObserver : public IntersectionObserver {

void CheckForIntersectionWithTarget() override;

bool HasUIAppearCallback() const {
return expose_attrs_.exposure_should_notify_appear_;
}

bool IsExposed() const {
return expose_attrs_.expose_state == ExposureState::kExposed;
}

// This fast path is restricted to a PageView's direct child on its first
// insertion. It bypasses geometry while preserving the normal exposure
// state machine and event payload.
bool TryNotifyAppearWithoutGeometry();

protected:
bool IsOfType(ObserverType type) const override {
return type == IntersectionObserver::kExposeObserver;
Expand Down
210 changes: 207 additions & 3 deletions clay/ui/component/intersection_observer_manager.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "clay/ui/component/intersection_observer_manager.h"

#include <algorithm>
#include <cmath>
#include <memory>
#include <utility>

Expand All @@ -16,6 +17,22 @@

namespace clay {

namespace {

constexpr double kLargeExposureTargetViewportAreaRatio = 0.5;

bool HasValidPositiveArea(const BaseView* view) {
return view && std::isfinite(view->Width()) &&
std::isfinite(view->Height()) && view->Width() > 0 &&
view->Height() > 0;
}

double Area(const BaseView* view) {
return static_cast<double>(view->Width()) * view->Height();
}

} // namespace

void IntersectionObserverManager::StopExposure(bool send_event) {
// Do not return only because exposure is already stopped. stop(false) keeps
// an active exposure open, so a later stop(true) must still be allowed to
Expand Down Expand Up @@ -73,20 +90,43 @@ void IntersectionObserverManager::SetExposureHostVisible(bool visible) {
it.second->SetExposureHostVisible(visible);
}

bool deferred_target_notified = false;
if (visible && !host_hidden_first_add_pending_views_.empty()) {
const std::vector<BaseView*> pending_targets(
host_hidden_first_add_pending_views_.begin(),
host_hidden_first_add_pending_views_.end());
host_hidden_first_add_pending_views_.clear();
for (auto* target : pending_targets) {
if (TryReconcileDeferredDirectPageChildAfterHostVisible(target)) {
deferred_target_notified = true;
}
}
}

if (visible && !expose_observers_map_.empty()) {
last_expose_time_ = -1;
page_view_->page_view()->RequestPaint();
}
if (deferred_target_notified) {
page_view_->page_view()->SendGlobalExposureEvent();
}
}

void IntersectionObserverManager::EraseExposeObserver(
const BaseView* view, const ExposeObserver* target) {
BaseView* view, const ExposeObserver* target) {
if (view) {
layout_fast_path_candidate_views_.erase(view);
layout_fast_path_attempted_views_.erase(view);
host_hidden_first_add_pending_views_.erase(view);
expose_observers_map_.erase(view);
} else if (target) {
for (auto iter = expose_observers_map_.begin();
iter != expose_observers_map_.end();) {
if ((iter->second).get() == target) {
auto* attached_view = iter->second->GetAttachedView();
layout_fast_path_candidate_views_.erase(attached_view);
layout_fast_path_attempted_views_.erase(attached_view);
host_hidden_first_add_pending_views_.erase(attached_view);
iter = expose_observers_map_.erase(iter);
return;
} else {
Expand All @@ -101,8 +141,7 @@ void IntersectionObserverManager::RemoveObserver(
if (target->IsOfType(IntersectionObserver::kIntersectionObserver)) {
EraseObserver(intersection_observers_, nullptr, target);
} else if (target->IsOfType(IntersectionObserver::kExposeObserver)) {
expose_observers_map_.erase(target->GetAttachedView());
EraseExposeObserver(nullptr, static_cast<const ExposeObserver*>(target));
EraseExposeObserver(target->GetAttachedView());
}
}

Expand Down Expand Up @@ -163,17 +202,182 @@ void IntersectionObserverManager::NotifyTargetAttached(BaseView* view) {
}

void IntersectionObserverManager::NotifyTargetDetached(BaseView* view) {
layout_fast_path_candidate_views_.erase(view);
layout_fast_path_attempted_views_.erase(view);
host_hidden_first_add_pending_views_.erase(view);
NotifyAllObserver(&IntersectionObserver::OnDetach, view);
}

void IntersectionObserverManager::ReconcileExposureForTarget(BaseView* view) {
if (view) {
layout_fast_path_candidate_views_.erase(view);
layout_fast_path_attempted_views_.insert(view);
}
if (exposure_stopped_ || !exposure_host_visible_ || !view ||
!view->attach_to_tree()) {
return;
}
NotifyExposures(&IntersectionObserver::CheckForIntersectionWithTarget, view);
}

bool IntersectionObserverManager::TryNotifyDirectPageChildOnFirstAdd(
BaseView* target) {
const bool direct_page_child =
target && page_view_ && target->Parent() == page_view_;
auto observer_it = expose_observers_map_.find(target);
auto* observer = observer_it == expose_observers_map_.end()
? nullptr
: observer_it->second.get();
const bool has_uiappear = observer && observer->HasUIAppearCallback();

if (!direct_page_child) {
return false;
}
if (!observer) {
return false;
}
if (!has_uiappear) {
return false;
}
if (!target->attach_to_tree()) {
return false;
}
if (exposure_stopped_) {
return false;
}
if (!exposure_host_visible_) {
host_hidden_first_add_pending_views_.insert(target);
return false;
}
host_hidden_first_add_pending_views_.erase(target);
layout_fast_path_candidate_views_.erase(target);
layout_fast_path_attempted_views_.insert(target);
if (observer->IsExposed()) {
page_view_->page_view()->SendGlobalExposureEvent();
return true;
}
if (!observer->TryNotifyAppearWithoutGeometry()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not mark offscreen direct children as exposed

When a direct PageView child registers uiappear before insertion but has zero-sized or offscreen bounds, the normal OnAttach geometry check correctly leaves it unexposed, but this call unconditionally invokes NotifyExposureEvent(true) without checking intersection. Because the feature is enabled by default, such a child emits a false uiappear and any configured global exposure event, enters kExposed, and may then emit an immediate uidisappear on the next geometry check despite never having been visible.

Useful? React with 👍 / 👎.

return false;
}

page_view_->page_view()->SendGlobalExposureEvent();
return true;
}

bool IntersectionObserverManager::
TryReconcileDeferredDirectPageChildAfterHostVisible(BaseView* target) {
const bool direct_page_child =
target && page_view_ && target->Parent() == page_view_;
auto observer_it = expose_observers_map_.find(target);
auto* observer = observer_it == expose_observers_map_.end()
? nullptr
: observer_it->second.get();
const bool has_uiappear = observer && observer->HasUIAppearCallback();

if (!direct_page_child) {
return false;
}
if (!observer) {
return false;
}
if (!has_uiappear) {
return false;
}
if (!target->attach_to_tree()) {
return false;
}
if (exposure_stopped_) {
return false;
}
if (!exposure_host_visible_) {
return false;
}
layout_fast_path_candidate_views_.erase(target);
layout_fast_path_attempted_views_.insert(target);
if (observer->IsExposed()) {
return false;
}

if (!observer->TryNotifyAppearWithoutGeometry()) {
return false;
}

return true;
}

bool IntersectionObserverManager::TryReconcileLargeExposureTargetAfterLayout(
BaseView* layout_view) {
if (!layout_view || !page_view_ || expose_observers_map_.empty() ||
!HasValidPositiveArea(page_view_)) {
return false;
}

const double page_area = Area(page_view_);
if (HasExposeObserver(layout_view) &&
layout_fast_path_attempted_views_.count(layout_view) == 0 &&
HasValidPositiveArea(layout_view) &&
Area(layout_view) > page_area * kLargeExposureTargetViewportAreaRatio) {
layout_fast_path_candidate_views_.insert(layout_view);
}
if (layout_fast_path_candidate_views_.empty()) {
return false;
}

// An observed target may receive layout before a full-screen wrapper. Only
// recheck the target when its own or one of its ancestors' bounds changes;
// unrelated layout updates do not scan the pending candidates.
bool reconciled = false;
const std::vector<BaseView*> candidates(
layout_fast_path_candidate_views_.begin(),
layout_fast_path_candidate_views_.end());
for (auto* target : candidates) {
if (!HasExposeObserver(target) ||
layout_fast_path_attempted_views_.count(target) != 0) {
layout_fast_path_candidate_views_.erase(target);
continue;
}

bool relevant_layout_update = layout_view == target;
for (auto* ancestor = target->Parent(); !relevant_layout_update && ancestor;
ancestor = ancestor->Parent()) {
relevant_layout_update = ancestor == layout_view;
if (ancestor == page_view_) {
break;
}
}
if (!relevant_layout_update) {
continue;
}

const bool attached = target->attach_to_tree();
bool large_ready_ancestor_chain = attached;
BaseView* current = target;
while (large_ready_ancestor_chain && current && current != page_view_) {
if (!HasValidPositiveArea(current) ||
Area(current) <= page_area * kLargeExposureTargetViewportAreaRatio) {
large_ready_ancestor_chain = false;
break;
}
current = current->Parent();
}

const bool page_descendant = current == page_view_;
const bool eligible = !exposure_stopped_ && exposure_host_visible_ &&
large_ready_ancestor_chain && page_descendant;
if (!eligible) {
continue;
}

ReconcileExposureForTarget(target);
reconciled = true;
}

if (reconciled) {
page_view_->page_view()->SendGlobalExposureEvent();
}
return reconciled;
}

void IntersectionObserverManager::NotifyExposures(
void (IntersectionObserver::*ptr)(), BaseView* view) {
for (auto& it : expose_observers_map_) {
Expand Down
9 changes: 8 additions & 1 deletion clay/ui/component/intersection_observer_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <map>
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include <vector>

#include "clay/ui/component/base_view.h"
Expand All @@ -34,6 +35,8 @@ class IntersectionObserverManager {
void NotifyTargetAttached(BaseView* view);
void NotifyTargetDetached(BaseView* view);
void ReconcileExposureForTarget(BaseView* view);
bool TryNotifyDirectPageChildOnFirstAdd(BaseView* view);
bool TryReconcileLargeExposureTargetAfterLayout(BaseView* view);

void RemoveExposeObserver(BaseView* view);
bool UpdateExposeData(const char* attr_key, const clay::Value& value,
Expand Down Expand Up @@ -61,9 +64,13 @@ class IntersectionObserverManager {
std::list<std::unique_ptr<IntersectionObserver>> intersection_observers_;
std::unordered_map<const BaseView*, std::unique_ptr<ExposeObserver>>
expose_observers_map_;
std::unordered_set<BaseView*> layout_fast_path_candidate_views_;
std::unordered_set<BaseView*> layout_fast_path_attempted_views_;
std::unordered_set<BaseView*> host_hidden_first_add_pending_views_;

void EraseExposeObserver(const BaseView* view = nullptr,
void EraseExposeObserver(BaseView* view = nullptr,
const ExposeObserver* target = nullptr);
bool TryReconcileDeferredDirectPageChildAfterHostVisible(BaseView* view);

template <class T>
void EraseObserver(std::list<T>& container, BaseView* attached_view,
Expand Down
4 changes: 4 additions & 0 deletions clay/ui/component/view_context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,10 @@ void ViewContext::SetBounds(int id, float left, float top, float width,
view->SetBound(
GetPageView()->RoundPixels(left), GetPageView()->RoundPixels(top),
GetPageView()->RoundPixels(width), GetPageView()->RoundPixels(height));
if (page_view_->HasIntersectionObserverManager()) {
page_view_->intersection_observer_manager()
->TryReconcileLargeExposureTargetAfterLayout(view);
}
}

void ViewContext::SetPaddings(int id, float padding_left, float padding_top,
Expand Down
Loading