Repository: electron/electron
Pull request: feat: Allow `View.setBounds` to animate

#### Description of Change

Add an optional options object to `View.setBounds` which, if specified, allows for the use of Chromium's `AnimationBuilder` to natively animate the bounds change of the view.

https://github.com/user-attachments/assets/01cfd678-9c27-4df5-a066-0833fc0bf1e1

#### Checklist

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Add animation functionality to `view.setBounds` and add `view.setBackgroundBlur`


Changed files:
filename: docs/api/view.md
patch: @@ -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()`
 

filename: shell/browser/api/electron_api_view.cc
patch: @@ -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<views::SizeBounds> {
         .Build();
   }
 };
+
+template <>
+struct Converter<gfx::Tween::Type> {
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> 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<View> 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<v8::Value> 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 {

filename: shell/browser/api/electron_api_view.h
patch: @@ -37,7 +37,7 @@ class View : public gin_helper::EventEmitter<View>,
                       std::optional<size_t> index);
   void RemoveChildView(gin_helper::Handle<View> 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<v8::Object> value);
   std::vector<v8::Local<v8::Value>> GetChildren();
@@ -70,6 +70,7 @@ class View : public gin_helper::EventEmitter<View>,
   void OnChildViewRemoved(views::View* observed_view,
                           views::View* child) override;
 
+  ui::Layer* GetLayer();
   void ApplyBorderRadius();
   void ReorderChildView(gin_helper::Handle<View> child, size_t index);
 

filename: spec/api-view-spec.ts
patch: @@ -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);
+    });
   });
 });