[gnome-break-timer] Add animated transitions to the CircleCounter widget



commit b4111b21d158e8a20febd1a358e724a4d776e2c8
Author: Dylan McCall <dylan dylanmccall ca>
Date:   Sun Nov 22 23:52:54 2020 -0800

    Add animated transitions to the CircleCounter widget

 src/settings/meson.build                |   3 +-
 src/settings/widgets/CircleCounter.vala |  88 ++++++++++++++----
 src/settings/widgets/Transition.vala    | 159 ++++++++++++++++++++++++++++++++
 3 files changed, 232 insertions(+), 18 deletions(-)
---
diff --git a/src/settings/meson.build b/src/settings/meson.build
index 38debc1..f4c5f3f 100644
--- a/src/settings/meson.build
+++ b/src/settings/meson.build
@@ -24,7 +24,8 @@ settings_lib_sources = files(
     'widgets/CircleCounter.vala',
     'widgets/FixedSizeGrid.vala',
     'widgets/OverlayArrow.vala',
-    'widgets/TimeChooser.vala'
+    'widgets/TimeChooser.vala',
+    'widgets/Transition.vala'
 )
 
 settings_lib_dependencies = [
diff --git a/src/settings/widgets/CircleCounter.vala b/src/settings/widgets/CircleCounter.vala
index 9ce5dfb..c4745a6 100644
--- a/src/settings/widgets/CircleCounter.vala
+++ b/src/settings/widgets/CircleCounter.vala
@@ -26,7 +26,17 @@ public class CircleCounter : Gtk.Widget {
     protected const double LINE_WIDTH = 5.0;
     protected const int DEFAULT_RADIUS = 48;
 
+    /* 10 seconds in microseconds */
+    private const int64 FULL_ANIM_TIME = (int64) (10000000 / (Math.PI * 2));
+
+    /* 10 ms in microseconds */
+    private const int64 MIN_ANIM_DURATION = 10000;
+
+    /* 500 ms in microseconds */
+    private const int64 MAX_ANIM_DURATION = 500000;
+
     private const double SNAP_INCREMENT = (Math.PI * 2) / 60.0;
+    private const double BASE_ANGLE = 1.5 * Math.PI;
 
     public enum Direction {
         COUNT_DOWN,
@@ -39,12 +49,18 @@ public class CircleCounter : Gtk.Widget {
      * COUNT_UP: a circle gradually appears as progress increases
      */
     public Direction direction {get; set;}
+
     /**
      * A value from 0.0 to 1.0, where 1.0 means the count is finished. The
      * circle will be filled by this amount according to the direction
      * property.
      */
-    public double progress {get; set;}
+    public double progress {set; get;}
+    public double draw_angle {set; get;}
+
+    private bool first_frame = true;
+
+    private PropertyTransition progress_transition;
 
     public CircleCounter () {
         GLib.Object ();
@@ -53,14 +69,57 @@ public class CircleCounter : Gtk.Widget {
 
         this.get_style_context ().add_class ("_circle-counter");
 
-        this.notify["progress"].connect((s, p) => {
-            this.queue_draw ();
-        });
+        this.progress_transition = new PropertyTransition (
+            this, "draw-angle", PropertyTransition.calculate_value_double
+        );
+
+        this.map.connect (this.on_map_cb);
+        this.draw.connect (this.on_draw_cb);
+        this.notify["progress"].connect (this.on_progress_notify_cb);
+        this.notify["draw-angle"].connect (this.on_draw_angle_notify_cb);
     }
 
-    // TODO: Animate between states <3
+    private void on_progress_notify_cb () {
+        double progress_angle = this.get_progress_angle ();
+
+        if (this.first_frame) {
+            this.progress_transition.skip (progress_angle);
+            this.first_frame = false;
+            return;
+        }
 
-    public override bool draw (Cairo.Context cr) {
+        // Animate at a consistent speed regardless of the distance covered.
+        double change = (progress_angle - this.draw_angle).abs ();
+        int64 duration = int64.min(
+            (int64) (change * FULL_ANIM_TIME),
+            MAX_ANIM_DURATION
+        );
+
+        if (duration < MIN_ANIM_DURATION) {
+            this.progress_transition.skip (progress_angle);
+        } else {
+            this.progress_transition.start (progress_angle, EASE_OUT_CUBIC, duration);
+        }
+    }
+
+    private void on_draw_angle_notify_cb () {
+        // TODO: Only redraw if the value has changed enough to be visible.
+        //       This will need a value set from the draw function.
+        GLib.info ("Draw angle %s", this.draw_angle.to_string ());
+        this.queue_draw ();
+    }
+
+    private double get_progress_angle () {
+        double result = (this.progress * Math.PI * 2.0) % (Math.PI * 2.0);
+        int snap_count = (int) (result / SNAP_INCREMENT);
+        return (double) snap_count * SNAP_INCREMENT;
+    }
+
+    private void on_map_cb () {
+        this.first_frame = true;
+    }
+
+    private bool on_draw_cb (Cairo.Context cr) {
         Gtk.StyleContext style_context = this.get_style_context ();
         Gtk.StateFlags state = this.get_state_flags ();
         Gtk.Allocation allocation;
@@ -83,28 +142,23 @@ public class CircleCounter : Gtk.Widget {
         cr.pop_group_to_source ();
         cr.paint_with_alpha (0.3);
 
-        double start_angle = 1.5 * Math.PI;
-        double progress_angle = this.progress * Math.PI * 2.0;
-        int snap_count = (int) (progress_angle / SNAP_INCREMENT);
-        progress_angle = snap_count * SNAP_INCREMENT;
-
         if (this.direction == Direction.COUNT_DOWN) {
-            if (progress_angle > 0) {
-                cr.arc (center_x, center_y, arc_radius, start_angle, start_angle - progress_angle);
+            if (this.draw_angle > 0) {
+                cr.arc (center_x, center_y, arc_radius, BASE_ANGLE, BASE_ANGLE - this.draw_angle);
             } else {
                 // No progress: Draw a full circle (to be gradually emptied)
-                cr.arc (center_x, center_y, arc_radius, start_angle, start_angle + Math.PI * 2.0);
+                cr.arc (center_x, center_y, arc_radius, BASE_ANGLE, BASE_ANGLE + Math.PI * 2.0);
             }
         } else {
-            if (progress_angle > 0) {
-                cr.arc_negative (center_x, center_y, arc_radius, start_angle, start_angle - progress_angle);
+            if (this.draw_angle > 0) {
+                cr.arc_negative (center_x, center_y, arc_radius, BASE_ANGLE, BASE_ANGLE - this.draw_angle);
             }
             // No progress: Draw nothing (arc will gradually appear)
         }
 
         Gdk.cairo_set_source_rgba (cr, foreground_color);
         cr.set_line_width (LINE_WIDTH);
-        cr.set_line_cap  (Cairo.LineCap.ROUND);
+        cr.set_line_cap  (Cairo.LineCap.SQUARE);
         cr.stroke ();
 
         return true;
diff --git a/src/settings/widgets/Transition.vala b/src/settings/widgets/Transition.vala
new file mode 100644
index 0000000..9f8c084
--- /dev/null
+++ b/src/settings/widgets/Transition.vala
@@ -0,0 +1,159 @@
+/*
+ * This file is part of GNOME Break Timer.
+ *
+ * GNOME Break Timer is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GNOME Break Timer is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with GNOME Break Timer.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace BreakTimer.Settings.Widgets {
+
+// TODO: I'm a little surprised I had to write this and I may be missing
+//       something important that already exists.
+
+/**
+ * Transition utility designed for Gtk.Widget's tick callback mechanism. Create
+ * an instance of this class with a particular output property for intermediate
+ * states, as well as a function to compute the value of that property given a
+ * start and end value and a easing ratio between the two.
+ */
+public class PropertyTransition : GLib.Object {
+    public delegate GLib.Value CalculateValue (GLib.Value start_value, GLib.Value end_value, double ease);
+
+    public enum EasingFunction {
+        LINEAR,
+        EASE_OUT_CUBIC;
+
+        public double calculate (double time) {
+            switch (this) {
+                case LINEAR:
+                    return this.linear (time);
+                case EASE_OUT_CUBIC:
+                    return this.ease_out_cubic (time);
+                default:
+                    GLib.assert_not_reached ();
+            }
+        }
+
+        private double linear (double time) {
+            return time;
+        }
+
+        /*
+         * From clutter-easing.c, based on Robert Penner's easing equations, MIT
+         * license.
+         */
+        private double ease_out_cubic (double time) {
+            double ease = time - 1;
+            return ease * ease * ease + 1;
+        }
+    }
+
+    private Gtk.Widget widget;
+    private string property_name;
+    private unowned CalculateValue calculate_value;
+
+    private GLib.Type property_type;
+
+    private EasingFunction easing_function;
+    private GLib.Value start_value;
+    private GLib.Value target_value;
+    private int64 start_frame_time;
+    private int64 end_frame_time;
+
+    private uint tick_callback_id;
+
+    public PropertyTransition (Gtk.Widget widget, string property_name, CalculateValue calculate_value) {
+        this.widget = widget;
+        this.property_name = property_name;
+        this.calculate_value = calculate_value;
+
+        GLib.ParamSpec? property_paramspec = widget.get_class ().find_property (property_name);
+        GLib.assert_nonnull (property_paramspec);
+        this.property_type = property_paramspec.value_type;
+
+        this.tick_callback_id = 0;
+    }
+
+    public bool start (GLib.Value target_value, EasingFunction easing_function, int64 duration_microseconds) 
{
+        GLib.warn_if_fail (target_value.type () == this.get_target_property ().type ());
+
+        Gdk.FrameClock? frame_clock = this.widget.get_frame_clock ();
+
+        if (frame_clock == null) {
+            return this.skip (target_value);
+        }
+
+        this.target_value = target_value;
+
+        this.easing_function = easing_function;
+        this.start_frame_time = frame_clock.get_frame_time ();
+        this.end_frame_time = this.start_frame_time + duration_microseconds;
+        this.start_value = this.get_target_property ();
+
+        if (this.tick_callback_id == 0) {
+            this.tick_callback_id = this.widget.add_tick_callback (this.tick_callback);
+        }
+
+        return true;
+    }
+
+    public bool skip (GLib.Value target_value) {
+        this.set_target_property (target_value);
+        return true;
+    }
+
+    private GLib.Value get_target_property () {
+        GLib.Value result = GLib.Value (this.property_type);
+        this.widget.get_property (this.property_name, ref result);
+        return result;
+    }
+
+    private void set_target_property (GLib.Value target_value) {
+        this.widget.set_property (this.property_name, target_value);
+    }
+
+    private bool tick_callback (Gtk.Widget widget, Gdk.FrameClock frame_clock) {
+        int64 now = frame_clock.get_frame_time ();
+        bool is_complete = this.set_frame (now);
+        if (is_complete) {
+            this.tick_callback_id = 0;
+            return GLib.Source.REMOVE;
+        } else {
+            return GLib.Source.CONTINUE;
+        }
+    }
+
+    private bool set_frame (int64 frame_time) {
+        bool is_complete = frame_time >= this.end_frame_time;
+
+        if (is_complete) {
+            frame_time = this.end_frame_time;
+        }
+
+        int64 time_delta = frame_time - this.start_frame_time;
+        int64 time_total = this.end_frame_time - this.start_frame_time;
+        double ease = this.easing_function.calculate ((double) time_delta / time_total);
+
+        this.set_target_property (
+            this.calculate_value (this.start_value, this.target_value, ease)
+        );
+
+        return is_complete;
+    }
+
+    public static GLib.Value calculate_value_double (GLib.Value start_value, GLib.Value end_value, double 
ease) {
+        return start_value.get_double () + ease * (end_value.get_double () - start_value.get_double ());
+    }
+}
+
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]