From f272723a33dcf1449527c56c8205ef1c6d4aa0bb Mon Sep 17 00:00:00 2001 From: axolotl <87679354+TheCommieAxolotl@users.noreply.github.com> Date: Sat, 31 Jan 2026 07:18:56 +1100 Subject: [PATCH] feat: Allow `View.setBounds` to animate (#48812) * feat: allow View::SetBounds to animate * fix: support width/height animations * fix: jumping on subsequent animations * fix: segfault race condition * fix: remove layer background * fix: layer clips not being reset * refactor: use gfx tween gin converter * fix: layer cleanups causing flickering views * chore: merge artifact * fix: missing private method in header * fix: return type * fix: do not set layer opacity * refactor: update animate parameter format * refactor: move animate into options object * chore: typo * docs: update * spec: add view animation test --- docs/api/view.md | 10 +- shell/browser/api/electron_api_view.cc | 133 ++++++++++++++++++++++++- shell/browser/api/electron_api_view.h | 3 +- spec/api-view-spec.ts | 13 +++ 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/docs/api/view.md b/docs/api/view.md index a3388a92e0..6dddd43c89 100644 --- a/docs/api/view.md +++ b/docs/api/view.md @@ -62,9 +62,17 @@ it becomes the topmost view. If the view passed as a parameter is not a child of this view, this method is a no-op. -#### `view.setBounds(bounds)` +#### `view.setBounds(bounds[, options])` * `bounds` [Rectangle](structures/rectangle.md) - New bounds of the View. +* `options` Object (optional) - Options for setting the bounds. + * `animate` boolean | Object (optional) - If true, the bounds change will be animated. If an object is passed, it can contain the following properties: + * `duration` Integer (optional) - Duration of the animation in milliseconds. Default is `250`. + * `easing` string (optional) - Easing function for the animation. Default is `linear`. + * `linear` + * `ease-in` + * `ease-out` + * `ease-in-out` #### `view.getBounds()` diff --git a/shell/browser/api/electron_api_view.cc b/shell/browser/api/electron_api_view.cc index 986ec06c84..37a0c54ce2 100644 --- a/shell/browser/api/electron_api_view.cc +++ b/shell/browser/api/electron_api_view.cc @@ -20,6 +20,8 @@ #include "shell/common/gin_helper/handle.h" #include "shell/common/gin_helper/object_template_builder.h" #include "shell/common/node_includes.h" +#include "ui/compositor/layer.h" +#include "ui/views/animation/animation_builder.h" #include "ui/views/background.h" #include "ui/views/layout/flex_layout.h" #include "ui/views/layout/layout_manager_base.h" @@ -144,6 +146,27 @@ struct Converter { .Build(); } }; + +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + gfx::Tween::Type* out) { + std::string easing = base::ToLowerASCII(gin::V8ToString(isolate, val)); + if (easing == "linear") { + *out = gfx::Tween::LINEAR; + } else if (easing == "ease-in") { + *out = gfx::Tween::EASE_IN; + } else if (easing == "ease-out") { + *out = gfx::Tween::EASE_OUT; + } else if (easing == "ease-in-out") { + *out = gfx::Tween::EASE_IN_OUT; + } else { + return false; + } + return true; + } +}; } // namespace gin namespace electron::api { @@ -280,10 +303,116 @@ void View::RemoveChildView(gin_helper::Handle child) { } } -void View::SetBounds(const gfx::Rect& bounds) { +ui::Layer* View::GetLayer() { + if (!view_) + return nullptr; + + if (view_->layer()) + return view_->layer(); + + view_->SetPaintToLayer(); + + ui::Layer* layer = view_->layer(); + + layer->SetFillsBoundsOpaquely(false); + + return layer; +} + +void View::SetBounds(const gfx::Rect& bounds, gin::Arguments* const args) { + bool animate = false; + int duration = 250; + gfx::Tween::Type easing = gfx::Tween::LINEAR; + + gin_helper::Dictionary dict; + if (args->GetNext(&dict)) { + v8::Local animate_value; + + if (dict.Get("animate", &animate_value)) { + if (animate_value->IsBoolean()) { + animate = animate_value->BooleanValue(isolate()); + } else { + animate = true; + + gin_helper::Dictionary animate_dict; + if (gin::ConvertFromV8(isolate(), animate_value, &animate_dict)) { + animate_dict.Get("duration", &duration); + animate_dict.Get("easing", &easing); + } + } + } + } + + if (duration < 0) + duration = 0; + if (!view_) return; - view_->SetBoundsRect(bounds); + + if (!animate) { + view_->SetBoundsRect(bounds); + return; + } + + ui::Layer* layer = GetLayer(); + + gfx::Rect current_bounds = view_->bounds(); + + if (bounds.size() == current_bounds.size()) { + // If the size isn't changing, we can just animate the bounds directly. + + views::AnimationBuilder() + .SetPreemptionStrategy( + ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET) + .OnEnded(base::BindOnce( + [](views::View* view, const gfx::Rect& final_bounds) { + view->SetBoundsRect(final_bounds); + }, + view_, bounds)) + .Once() + .SetDuration(base::Milliseconds(duration)) + .SetBounds(view_, bounds, easing); + + return; + } + + gfx::Rect target_size = gfx::Rect(0, 0, bounds.width(), bounds.height()); + gfx::Rect max_size = + gfx::Rect(current_bounds.x(), current_bounds.y(), + std::max(current_bounds.width(), bounds.width()), + std::max(current_bounds.height(), bounds.height())); + + // if the view's size is smaller than the target size, we need to set the + // view's bounds immediatley to the new size (not position) and set the + // layer's clip rect to animate from there. + if (view_->width() < bounds.width() || view_->height() < bounds.height()) { + view_->SetBoundsRect(max_size); + + if (layer) { + layer->SetClipRect( + gfx::Rect(0, 0, current_bounds.width(), current_bounds.height())); + } + } + + views::AnimationBuilder() + .SetPreemptionStrategy( + ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET) + .OnEnded(base::BindOnce( + [](views::View* view, const gfx::Rect& final_bounds, + ui::Layer* layer) { + view->SetBoundsRect(final_bounds); + if (layer) + layer->SetClipRect(gfx::Rect()); + }, + view_, bounds, layer)) + .Once() + .SetDuration(base::Milliseconds(duration)) + .SetBounds(view_, bounds, easing) + .SetClipRect( + view_, target_size, + easing); // We have to set the clip rect independently of the + // bounds, because animating the bounds of the layer + // will not animate the underlying view's bounds. } gfx::Rect View::GetBounds() const { diff --git a/shell/browser/api/electron_api_view.h b/shell/browser/api/electron_api_view.h index 2d1318e134..c05cbe3146 100644 --- a/shell/browser/api/electron_api_view.h +++ b/shell/browser/api/electron_api_view.h @@ -37,7 +37,7 @@ class View : public gin_helper::EventEmitter, std::optional index); void RemoveChildView(gin_helper::Handle child); - void SetBounds(const gfx::Rect& bounds); + void SetBounds(const gfx::Rect& bounds, gin::Arguments* args); gfx::Rect GetBounds() const; void SetLayout(v8::Isolate* isolate, v8::Local value); std::vector> GetChildren(); @@ -70,6 +70,7 @@ class View : public gin_helper::EventEmitter, void OnChildViewRemoved(views::View* observed_view, views::View* child) override; + ui::Layer* GetLayer(); void ApplyBorderRadius(); void ReorderChildView(gin_helper::Handle child, size_t index); diff --git a/spec/api-view-spec.ts b/spec/api-view-spec.ts index fc39bccb29..dc3deaf53f 100644 --- a/spec/api-view-spec.ts +++ b/spec/api-view-spec.ts @@ -133,5 +133,18 @@ describe('View', () => { parent.setBounds({ x: 50, y: 60, width: 500, height: 600 }); expect(child.getBounds()).to.deep.equal({ x: 10, y: 15, width: 25, height: 30 }); }); + + it('can set bounds with animation', (done) => { + const v = new View(); + v.setBounds({ x: 0, y: 0, width: 100, height: 100 }, { + animate: { + duration: 300 + } + }); + setTimeout(() => { + expect(v.getBounds()).to.deep.equal({ x: 0, y: 0, width: 100, height: 100 }); + done(); + }, 350); + }); }); });