├── images ├── demo.gif └── 2022-09-24-humbleui-animations.webm ├── dev └── user.clj ├── .gitignore ├── script └── run.sh ├── deps.edn ├── LICENSE.md ├── README.md └── src └── humble_animations ├── ui.clj ├── main.clj └── animate.clj /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oakmac/humble-animations/HEAD/images/demo.gif -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require 3 | [humble-animations.main :as main])) 4 | 5 | (main/-main) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache/ 2 | target/ 3 | .nrepl-port 4 | .rebel_readline_history 5 | hs_err_pid*.log 6 | *.diff 7 | -------------------------------------------------------------------------------- /images/2022-09-24-humbleui-animations.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oakmac/humble-animations/HEAD/images/2022-09-24-humbleui-animations.webm -------------------------------------------------------------------------------- /script/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit -o nounset -o pipefail 3 | cd "$(dirname "$0")/.." 4 | 5 | clj -M -m humble-animations.main $@ 6 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps 2 | {org.clojure/clojure {:mvn/version "1.11.1"} 3 | io.github.humbleui/humbleui {:git/sha "774853e4ec912168ad96fff96c0296dda98531f6"} 4 | tween-clj/tween-clj {:mvn/version "0.5.0"}}} 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022, Chris Oakman 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose 6 | with or without fee is hereby granted, provided that the above copyright notice 7 | and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 13 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 15 | THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Animations in HumbleUI 2 | 3 | This repo is an example of doing animations in [HumbleUI]. 4 | 5 | [HumbleUI]:https://github.com/HumbleUI/HumbleUI 6 | 7 | ## Demo 8 | 9 | > Please note the .gif image below does not show animations at their full frame-rate. 10 | 11 | 12 | 13 | A higher frame-rate example can be found [here] or [online]. 14 | 15 | [here]:images/2022-09-24-humbleui-animations.webm 16 | [online]:https://oakmac.com/2022-09-24-humbleui-animations.webm 17 | 18 | ## Development 19 | 20 | Make sure that [Clojure v1.11.1](https://clojure.org/releases/downloads) is installed, then: 21 | 22 | ```sh 23 | ## run the program 24 | ./script/run.sh 25 | 26 | ## Run a REPL 27 | clj -M:env/dev:repl/rebel 28 | ``` 29 | 30 | ## Future Development / TODO 31 | 32 | - [ ] publish as a generic library 33 | - [ ] performance analysis and tweaking of the `tick!` function 34 | 35 | ## License 36 | 37 | [ISC License](LICENSE.md) 38 | -------------------------------------------------------------------------------- /src/humble_animations/ui.clj: -------------------------------------------------------------------------------- 1 | (ns humble-animations.ui 2 | "Creates the animation-ticker wrapper component, which is used to trigger animate/tick! 3 | after a draw operation" 4 | (:require 5 | [humble-animations.animate :as animate] 6 | [io.github.humbleui.core :as core] 7 | [io.github.humbleui.protocols :as protocols]) 8 | (:import 9 | [io.github.humbleui.types IRect] 10 | [java.lang AutoCloseable])) 11 | 12 | (core/deftype+ AnimationTicker [child ^:mut ^IRect child-rect] 13 | protocols/IContext 14 | (-context [_ ctx] 15 | ctx) 16 | 17 | protocols/IComponent 18 | (-measure [this ctx cs] 19 | (core/measure child (protocols/-context this ctx) cs)) 20 | 21 | (-draw [this ctx rect ^Canvas canvas] 22 | (set! child-rect rect) 23 | (core/draw-child child (protocols/-context this ctx) child-rect canvas) 24 | ;; call animate/tick! after we have drawn this components children 25 | (animate/tick!)) 26 | 27 | (-event [_ ctx event] 28 | (core/event-child child ctx event)) 29 | 30 | (-iterate [this ctx cb] 31 | (or 32 | (cb this) 33 | (protocols/-iterate child ctx cb))) 34 | 35 | AutoCloseable 36 | (close [_] 37 | (core/child-close child))) 38 | 39 | (defn animation-ticker [child] 40 | (->AnimationTicker child nil)) 41 | -------------------------------------------------------------------------------- /src/humble_animations/main.clj: -------------------------------------------------------------------------------- 1 | (ns humble-animations.main 2 | (:require 3 | [humble-animations.animate :as animate] 4 | [humble-animations.ui :as animate-ui] 5 | [io.github.humbleui.debug :as debug] 6 | [io.github.humbleui.paint :as paint] 7 | [io.github.humbleui.ui :as ui] 8 | [io.github.humbleui.ui.clickable :as clickable] 9 | [io.github.humbleui.window :as window])) 10 | 11 | (set! *warn-on-reflection* true) 12 | 13 | (reset! debug/*enabled? true) 14 | 15 | (def dark-grey 0xff404040) 16 | (def light-grey 0xffeeeeee) 17 | (def blue 0xff0d7fbe) 18 | (def yellow 0xfffae317) 19 | (def red 0xfff50f0f) 20 | (def white 0xfff3f3f3) 21 | (def black 0xff000000) 22 | 23 | (defonce *window 24 | (atom nil)) 25 | 26 | (defn redraw! 27 | "Requests a redraw on the next available frame." 28 | [] 29 | (some-> @*window window/request-frame)) 30 | 31 | (animate/set-request-frame-fn! redraw!) 32 | 33 | ;; TODO: is there an empty component I can use instead of label = "" ? 34 | (defn Box [color] 35 | (ui/rect (paint/fill color) (ui/label ""))) 36 | 37 | (def initial-app-state 38 | {:red-box-height-px 50 39 | :red-box-width-px 50 40 | 41 | :red-box-left-pct 0 42 | :red-box-top-pct 0}) 43 | 44 | (def *app-state 45 | (atom initial-app-state)) 46 | 47 | (defn reset-app-state! [] 48 | (reset! *app-state initial-app-state) 49 | (redraw!)) 50 | 51 | (def layout-padding-px 10) 52 | 53 | (def locations 54 | {:top-left {:left 0 :top 0} 55 | :top-right {:left 1 :top 0} 56 | :center {:left 0.5 :top 0.5} 57 | :bottom-left {:left 0 :top 1} 58 | :bottom-right {:left 1 :top 1}}) 59 | 60 | (def red-box-animate-speed-ms 250) 61 | 62 | (defn animate-red-box-location! 63 | ([] 64 | (animate-red-box-location! 0 0)) 65 | ([new-left-pct new-top-pct] 66 | (let [animation-mode (rand-nth [:single-vals :vector :map]) 67 | {:keys [red-box-left-pct red-box-top-pct]} @*app-state] 68 | (case animation-mode 69 | :single-vals ;; Option 1) animate each property individually 70 | (do 71 | (animate/start-animation! 72 | {:start-val red-box-left-pct 73 | :end-val new-left-pct 74 | :duration-ms red-box-animate-speed-ms 75 | :on-tick (fn [{:keys [_time val]}] 76 | (swap! *app-state assoc :red-box-left-pct val)) 77 | :transition :ease-out-pow}) 78 | (animate/start-animation! 79 | {:start-val red-box-top-pct 80 | :end-val new-top-pct 81 | :duration-ms red-box-animate-speed-ms 82 | :on-tick (fn [{:keys [_time val]}] 83 | (swap! *app-state assoc :red-box-top-pct val)) 84 | :transition :ease-out-pow 85 | ;; optional on-stop function will fire when the animation has finished 86 | :on-stop (fn [anim] 87 | (println "Animation has finished:") 88 | (println (pr-str anim)))})) 89 | 90 | :vector ;; Option 2) animate multiple properties using a Vector 91 | (animate/start-animation! 92 | {:start-val [red-box-left-pct red-box-top-pct] 93 | :end-val [new-left-pct new-top-pct] 94 | :duration-ms red-box-animate-speed-ms 95 | :on-tick (fn [{:keys [_time val]}] 96 | (swap! *app-state assoc :red-box-left-pct (first val) 97 | :red-box-top-pct (second val))) 98 | :transition :ease-out-pow}) 99 | 100 | :map ;; Option 3) animate multiple properties using a Map 101 | (animate/start-animation! 102 | {:start-val {:left red-box-left-pct :top red-box-top-pct} 103 | :end-val {:left new-left-pct :top new-top-pct} 104 | :duration-ms red-box-animate-speed-ms 105 | :on-tick (fn [{:keys [_time val]}] 106 | (swap! *app-state assoc :red-box-left-pct (:left val) 107 | :red-box-top-pct (:top val))) 108 | :transition :ease-out-pow}))))) 109 | 110 | (defn animate-red-box-size! 111 | [width-px height-px] 112 | (let [{:keys [red-box-width-px red-box-height-px]} @*app-state] 113 | (animate/start-animation! 114 | {:start-val [red-box-width-px red-box-height-px] 115 | :end-val [width-px height-px] 116 | :duration-ms red-box-animate-speed-ms 117 | :on-tick (fn [{:keys [_time val]}] 118 | (swap! *app-state assoc :red-box-width-px (first val) 119 | :red-box-height-px (second val))) 120 | :transition :ease-out-pow}))) 121 | 122 | (def ButtonsColumn 123 | (ui/padding layout-padding-px 124 | (ui/valign 0 125 | (ui/column 126 | (ui/button #(animate-red-box-location! 0 0) (ui/label "Top Left")) 127 | (ui/gap 0 10) 128 | (ui/button #(animate-red-box-location! 1 0) (ui/label "Top Right")) 129 | (ui/gap 0 10) 130 | (ui/button #(animate-red-box-location! 0.5 0.5) (ui/label "Center")) 131 | (ui/gap 0 10) 132 | (ui/button #(animate-red-box-location! 0 1) (ui/label "Bottom Left")) 133 | (ui/gap 0 10) 134 | (ui/button #(animate-red-box-location! 1 1) (ui/label "Bottom Right")) 135 | (ui/gap 0 10) 136 | (ui/button 137 | (fn [] 138 | (let [rand-left (/ (rand-int 101) 100) 139 | rand-top (/ (rand-int 101) 100)] 140 | (animate-red-box-location! rand-left rand-top))) 141 | (ui/label "Random!")) 142 | 143 | (ui/gap 0 30) 144 | 145 | (ui/button #(animate-red-box-size! 25 25) (ui/label "Small Box")) 146 | (ui/gap 0 10) 147 | (ui/button #(animate-red-box-size! 50 50) (ui/label "Medium Box")) 148 | (ui/gap 0 10) 149 | (ui/button #(animate-red-box-size! 100 100) (ui/label "Large Box")))))) 150 | 151 | (def Separator 152 | (ui/rect (paint/fill light-grey) 153 | (ui/gap 2 0))) 154 | 155 | (def RedBox 156 | (ui/dynamic _ctx [box-width (:red-box-width-px @*app-state) 157 | box-height (:red-box-height-px @*app-state)] 158 | (ui/clip-rrect 4 159 | (ui/rect (paint/fill red) 160 | (ui/gap box-width box-height))))) 161 | 162 | (def AnimationArea 163 | (ui/padding layout-padding-px 164 | (ui/stack 165 | (ui/dynamic _ctx [box-left (:red-box-left-pct @*app-state) 166 | box-top (:red-box-top-pct @*app-state)] 167 | (ui/halign box-left 168 | (ui/valign box-top 169 | RedBox)))))) 170 | 171 | (def HumbleAnimations 172 | "top-level component" 173 | (ui/default-theme {} 174 | ;; animation wrapper component is required in order to call animate/tick! after every draw operation 175 | (animate-ui/animation-ticker 176 | (ui/row 177 | ButtonsColumn 178 | Separator 179 | [:stretch 1 AnimationArea])))) 180 | 181 | ;; re-draw the UI when we load this namespace 182 | (redraw!) 183 | 184 | (defn -main [& args] 185 | (ui/start-app! 186 | (reset! *window 187 | (ui/window 188 | {:title "HumbleUI Animations" 189 | :bg-color 0xFFFFFFFF} 190 | #'HumbleAnimations)))) 191 | -------------------------------------------------------------------------------- /src/humble_animations/animate.clj: -------------------------------------------------------------------------------- 1 | (ns humble-animations.animate 2 | "Manages a queue of animations and provides a tick! function to implement 3 | incremental progress as the animation occurs." 4 | (:require 5 | [tween-clj.core :as tween])) 6 | 7 | (def *request-frame-fn (atom nil)) 8 | 9 | (defn now 10 | "returns the current time in milliseconds" 11 | [] 12 | (System/currentTimeMillis)) 13 | 14 | (def transitions 15 | {:ease-in (partial tween/ease-in tween/transition-linear) 16 | :ease-out (partial tween/ease-out tween/transition-linear) 17 | :ease-in-out (partial tween/ease-in-out tween/transition-linear) 18 | 19 | :ease-in-circ (partial tween/ease-in tween/transition-circ) 20 | :ease-out-circ (partial tween/ease-out tween/transition-circ) 21 | :ease-in-out-circ (partial tween/ease-in-out tween/transition-circ) 22 | 23 | :ease-in-expo (partial tween/ease-in tween/transition-expo) 24 | :ease-out-expo (partial tween/ease-out tween/transition-expo) 25 | :ease-in-out-expo (partial tween/ease-in-out tween/transition-expo) 26 | 27 | :ease-in-pow (partial tween/ease-in tween/transition-pow) 28 | :ease-out-pow (partial tween/ease-out tween/transition-pow) 29 | :ease-in-out-pow (partial tween/ease-in-out tween/transition-pow) 30 | 31 | :ease-in-sine (partial tween/ease-in tween/transition-sine) 32 | :ease-out-sine (partial tween/ease-out tween/transition-sine) 33 | :ease-in-out-sine (partial tween/ease-in-out tween/transition-sine)}) 34 | 35 | (defn convert-vals-to-doubles 36 | "ensure that values are doubles for the tweening calculations" 37 | [v] 38 | (cond 39 | (number? v) (double v) 40 | (vector? v) (mapv double v) 41 | (map? v) (zipmap (keys v) (map double (vals v))) 42 | :else 0)) ;; TODO: should we warn here? 43 | 44 | ;; TODO: good candidate for unit tests 45 | (defn calc-tween-val 46 | "Calculates a single 'tween value for a specific time" 47 | [{:keys [start-val end-val start-time end-time current-time transition]}] 48 | (let [inverted? (> start-val end-val) 49 | start-val' (if inverted? end-val start-val) 50 | end-val' (if inverted? start-val end-val) 51 | default-transition-fn (:ease-in transitions) 52 | shorthand-fn (get transitions transition) 53 | transition-fn (cond 54 | shorthand-fn shorthand-fn 55 | (fn? transition) transition 56 | :else default-transition-fn) 57 | ;; a p-val is a number between 0 and 1, used for the transition functions 58 | p-val (tween/range-to-p start-time end-time current-time) 59 | tween-val (tween/p-to-range start-val' end-val' (transition-fn p-val))] 60 | (if-not inverted? 61 | tween-val 62 | (let [delta-from-start-val (- tween-val start-val')] 63 | (- start-val delta-from-start-val))))) 64 | 65 | ;; TODO: good candidate for unit tests 66 | (defn calc-tween-vals 67 | [{:keys [start-val end-val] :as animation}] 68 | (cond 69 | (number? start-val) (calc-tween-val animation) 70 | (sequential? start-val) (map-indexed 71 | (fn [idx s-val] 72 | (-> animation 73 | (assoc :start-val s-val 74 | :end-val (nth end-val idx)) 75 | calc-tween-val)) 76 | start-val) 77 | (map? start-val) (zipmap (keys start-val) 78 | (map-indexed 79 | (fn [idx s-val] 80 | (-> animation 81 | (assoc :start-val s-val 82 | :end-val (nth (vals end-val) idx)) 83 | calc-tween-val)) 84 | (vals start-val))) 85 | ;; NOTE: this should not happen 86 | :else nil)) 87 | 88 | ;; FIXME: not working yet 89 | (def animating? 90 | "Are we currently animating?" 91 | (atom false)) 92 | 93 | (def animations-queue 94 | (atom {})) 95 | 96 | (defn first-val 97 | "returns the first value from either a number, vector, or map of values" 98 | [v] 99 | (cond 100 | (number? v) v 101 | (sequential? v) (first v) 102 | (map? v) (-> v vals first) 103 | :else nil)) 104 | 105 | (defn tick! 106 | "This function is called continuously while an animation is active." 107 | [] 108 | (let [queue @animations-queue 109 | n (now) 110 | stops (atom [])] 111 | ;; process the queue 112 | (doseq [[id animation] queue] 113 | (let [{:keys [id start-val end-val end-time on-tick]} animation 114 | ;; calculate the tween value for this animation 115 | tween-val (calc-tween-vals (assoc animation :current-time n)) 116 | first-starting-val (first-val start-val) 117 | first-ending-val (first-val end-val) 118 | first-tween-val (first-val tween-val) 119 | ascending? (< first-starting-val first-ending-val) 120 | time-to-stop? (or 121 | ;; we have reached their end-val 122 | (if ascending? 123 | (>= first-tween-val first-ending-val) 124 | (<= first-tween-val first-ending-val)) 125 | ;; it is past time 126 | (> n end-time))] 127 | ;; run their provided on-tick function with the val 128 | (when (fn? on-tick) 129 | (on-tick {:time n 130 | :val tween-val})) 131 | ;; is it time to stop this animation? 132 | (when time-to-stop? 133 | (swap! stops conj animation)))) 134 | 135 | ;; request a redraw if there are any animations in the animations-queue 136 | ;; hopefully the user has done something useful with their on-tick function 137 | ;; so we may see the animation! 138 | (let [request-frame! @*request-frame-fn] 139 | (when (and request-frame! (not (empty? queue))) 140 | (request-frame!))) 141 | 142 | ;; process any stops 143 | (doseq [{:keys [id on-stop] :as anim} @stops] 144 | ;; run their on-stop function if they provided one 145 | (when (fn? on-stop) 146 | (on-stop (-> anim 147 | (assoc :id id) 148 | (dissoc :on-stop) 149 | (dissoc :on-tick)))) 150 | ;; remove from the animations queue 151 | (swap! animations-queue dissoc id)))) 152 | 153 | ;; ----------------------------------------------------------------------------- 154 | ;; Public API 155 | 156 | (defn set-request-frame-fn! [f] 157 | (reset! *request-frame-fn f)) 158 | 159 | ;; TODO: make this vardiadic, accept multiple animations 160 | (defn start-animation! 161 | [{:keys [start-val end-val duration-ms on-tick transition] :as animation}] 162 | (let [request-frame! @*request-frame-fn] 163 | ;; sanity-checks: 164 | (assert (fn? request-frame!) "Please set the *request-frame-fn before calling start-animation!") 165 | (when (number? start-val) 166 | (assert (number? end-val) "start-val and end-val must both be numbers")) 167 | (when (sequential? start-val) 168 | (assert (and (sequential? end-val) 169 | (= (count start-val) (count end-val)) 170 | (every? number? start-val) 171 | (every? number? end-val)) 172 | "start-val and end-val should be vectors of the same length and contain only numbers")) 173 | (when (map? start-val) 174 | (assert (and (map? end-val) 175 | (= (keys start-val) (keys end-val)) 176 | (every? number? (vals start-val)) 177 | (every? number? (vals end-val))) 178 | "start-val and end-val must be maps with the same keys and contain only number values")) 179 | 180 | ;; create the animation 181 | (let [start-time (now) 182 | end-time (+ start-time duration-ms) 183 | id (str (random-uuid)) 184 | animation' (assoc animation :id id 185 | :start-time start-time 186 | :end-time end-time 187 | :start-val (convert-vals-to-doubles start-val) 188 | :end-val (convert-vals-to-doubles end-val))] 189 | ;; add this animation to the queue 190 | (swap! animations-queue assoc id animation') 191 | 192 | ;; trigger a redraw on the next frame, which should call tick! via the animation-ticker component 193 | (request-frame!) 194 | 195 | ;; return the id of their new animation 196 | id))) 197 | 198 | ;; TODO: 199 | ;; - stop-animation! 200 | ;; - stop-all-animations! 201 | --------------------------------------------------------------------------------