CSS animasjoner i ClojureScript - Del 1: CLJSS

Magnus om CSS og ClojureScript

Publisert 16.10.2019

CSS-animasjoner kan være et artig krydder i web applikasjoner. Noen ganger er det mer enn bare krydder. I 2018 fikk jeg sjansen til å jobbe i et spillprosjekt. Da ble det etterhvert mange og sammensatte animasjoner som bød på flere spennende utfordringer. Prosjektet valgte å bruke ClojureScript på frontend. Det er et deilig funksjonelt språk som kompilerer til JavaScript. I denne bloggserien tenkte jeg å skrive litt om noen av temaene og utfordringene vi traff på underveis.

Første tema jeg tenkte å ta for meg var CSS in JS. Finnes det noe biblioteker vi kan bruke som lar oss skrive CSS i ClojureScript? Hvilke fordeler og ulemper har et slikt bibliotek og hva har det med animasjoner å gjøre?

Virtual DOM

React populariserte konseptet Virtual DOM. Det lar oss deklarativt beskrive hvordan vi ønsker at UI’et vårt skal se ut (basert på gjeldende tilstand i applikasjonen vår). Dette høres veldig funksjonelt ut og er et konsept man omfavner i ClojureScript verdenen. Det finnes mange populære React wrappere i ClojureScript. Vi kunne brukt noen av disse, men jeg tror vi klarer oss helt fint med noe mer lettvekts og rendyrket. Derfor kjører vi på med cjohansen/dumdom.

CSS i ClojureScript

Den enkleste veien for å få til animasjoner i ClojureScript (og forsåvidt JavaScript også) er kanskje å definere css-klasser i en css fil (med transitions eller keyframes). Så skifter du mellom klasser basert på tilstand i applikasjonen din (som endres som følge av hendelser), for å trigge animasjonene.

;; En tenkt clojurescript komponent som representerer en spinner.
;; All styling for komponenten og hvordan animere den er definert via css klassene ".spinner" og ".animate"
(defcomponent Spinner [animate?]
  [:div {:class (str "spinner" (when animate? " animate"))}])

Dette er jo ganske rett frem. Men hva om du ønsker å ha mer kontroll over animasjonen gjennom komponenten i din kjørende applikasjon? F.eks dynamisk bestemme hvor fort den skal spinne.

;; En tenkt clojurescript komponent som representerer en spinner.
;; Styling er bestemt via en ".spinner" klasse.
;; Selve deklarajonen av animasjonen er definert via en CSS @keyframes regel her kalt "rotate".
(defcomponent Spinner [animate? speed]
  [:div (merge {:class "spinner"}
               (when animate?
                 {:style {:animation (str "rotate " speed "ms linear infinite")}}))])

Ok. Så med litt mer kode og kombinasjon av css klasse og inline styles (:animation) har vi nå fått mer kontroll over animasjonen. Et alternativ til foregående løsning, kunne vært å bruke css variabler. Da kunne det sett mer likt ut som vårt første eksempel.

;; En tenkt clojurescript komponent som representerer en spinner.
;; All styling for komponenten og hvordan animere den er definert via css klassene ".spinner" og ".animate".
;; Farten på animasjonen kan overstyres med css variabelen "--rotate-speed"
(defcomponent Spinner [animate? speed]
  [:div (merge {:class (str "spinner" (when animate? " animate"))}
               (when animate? {:style {"--rotate-speed" "1500"}}))])

Ja dette er sikkert fint altså. Men hva om jeg ønsker meg en komponent som står helt på egne ben og ikke er avhengig av en ekstern css fil? Kan jeg få styling og animasjonsdeklarasjon samlokalisert med med komponentkoden min på noe vis?

Det kan du. Du kan mikke det til på egenhånd med nennsom mutasjon av DOM’en (legge til en style tag). Så lenge stylingen din er rett frem og du ikke trenger css ting som :after, :before, :hover, media queries osv, så kan du komme langt med inline styles. Trenger du css stuffs som ikke lar seg representere via inline styles derimot så vokser jo oppgaven litt. I ClojureScript verdenen finnes det endel biblioteker du kan benytte deg av. De to mest omfattende er cljss og stylefy. Stylefy funker visst bare med React wrapperen reagent. Da får vi gi cljss et forsøk i denne omgang.

Oppsett

Før vi dykker ned i kodeeksempler må vi gjøre litt initielt oppsett for å komme opp å kjøre. Vi har lyst på hot-reloading (bevare tilstand? ja takk). Vi ønsker oss også enkelt å kunne teste hvordan komponenter og animasjoner blir seende ut hver for seg. Kan vi få slippe veldig mye oppsett med byggeverktøy og slikt også så hadde det vært fint.

  • Figwheel gir deg hotloading. Sweet.
  • Devcards lar deg interaktivt se på og modifisere komponenter fra applikasjonen din, uten å sause det inn i selve applikasjonen. Høres bra ut.
  • tools.deps lar deg trekke inn avhengigheter uten å tenke på bygg og slikt nå

Men hvordan setter man opp disse tingene i samspill? Flaks at min kollega Christian skrev en bloggpost vi kan følge.

Slik ble min fil for avhengigheter (deps.edn)

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.10.1"}
        org.clojure/clojurescript {:mvn/version "1.10.520"}
        cjohansen/dumdom {:mvn/version "2019.09.05-1"}
        clj-commons/cljss {:mvn/version "1.6.4"}}
 :aliases {:dev {:extra-paths ["resources"]
                 :extra-deps {com.bhauman/figwheel-main {:mvn/version "0.2.3"}
                              devcards {:mvn/version "0.2.6"}}}}}

Utviklingsprofilen min (dev.cljs.edn) endte opp slik

^{:ring-server-options {:port 9550}
  :watch-dirs ["src" "devcards"]
  :extra-main-files {:devcards {:main cljs-animations.cards}}}
{:main cljs-animations.core
 :devcards true
 :optimizations :none
 :pretty-print true
 :source-map true
 :asset-path "/js/dev"
 :output-to "resources/public/js/dev.js"
 :output-dir "resources/public/js/dev"}

Vi begynner med en spinner

Spinnerkomponent

La oss begynne med å lage et namespace for komponenter. Lag en fil src/cljs_animations/components.cljs. På toppen av fila skal det stå noe slikt:

(ns cljs-animations.components
  (:require [dumdom.core :as q]
            [cljss.core :as css :refer-macros [defstyles defkeyframes]])
  (:require-macros [cljss.core]))

Her importerer vi dumdum og cljss slik at vi kan bruke dem til å lage spinnerkomponenten vår.

Vi peiser på med å lage spinnerkomponenten vår.

(def spinner-sizes
  {:m "16px"
   :l "32px"
   :xl "64px"})

(def colors
  {:blue "0,0,255"
   :red "255,0,0"
   :green "0,255,0"})

;; Lager en @keyframes regel. Denne injectes i DOM'en automagisk.
(defkeyframes spinner-frames []
  {:from {:transform "rotate(0deg)"}
   :to   {:transform "rotate(360deg)"}})

;; Lager en css klasse som får et unikt navn basert på navnerommet til clojurescript fila vår og navnet på stilen.
;; Stilen "injectes" i Head som en style tag i DOMen.
(defstyles spinner-styles [{:keys [size color]}] ;; input parametere blir til css variable !
  {:width         (spinner-sizes size)
   :height        (spinner-sizes size)
   :border-radius "50%"
   :border-top    (str "2px solid rgb(" (color colors) ")" )
   :border-left   (str "2px solid rgba(" (color colors) ",0.3)")
   :border-bottom (str "2px solid rgba(" (color colors) ",0.3)")
   :border-right  (str "2px solid rgba(" (color colors) ",0.3)")
   ;; (spinner-frames) kallet gir deg animasjonsnavnet for keyframe-definisjonen over.
   :animation     (str (spinner-frames) " 1500ms linear infinite")})

;; Selve spinnerkomponenten vår...
;; Med parametere til styling (og evt keyframes dersom vi skulle trenge det)
(q/defcomponent Spinner [params]
  [:div {:class (spinner-styles params)}])

Spinner demo

For å teste komponenten vår kan vi bruke devcards. La oss begynne med å lage en fil /devcards/cljs_animations/cards.cljs dersom du ikke allerede har gjort det.

(ns ^:figwheel-hooks cljs-animations.cards
  (:require  [cljs-animations.components :as components]
             [cljss.core :as css]
             [dumdom.devcards :refer-macros [defcard]]
             [dumdom.core :as q]
             [dumdom.dom :as d])
  (:require-macros [cljss.core]))

(enable-console-print!)

;; Her lager vi et kort for å demonstrere spinnerkomponenten vår i litt forskjellige inkarnasjoner
(defcard
  [:div {:style {:display "flex"
                 :justify-content "space-evenly"
                 :align-items "center"}}
   (components/Spinner {:size :m :color :blue})
   (components/Spinner {:size :l :color :red})
   (components/Spinner {:size :xl :color :green})])

(defn render []
  (devcards.core/start-devcard-ui!))

(defn ^:after-load render-on-reload []
  ;; Figwheel reloader hver gang vi lagrer en endring, det gjør at det lages nye styles i DOM'en.
  ;; Denne funksjonen rydder opp, slik at vi kan holde på av hjertens lyst med endringer i kompoent navnerommet.
  ;; Ulempen (som jeg ikke har funnet noen løsning på) er at endringer i dette navnerommet ikke re-injekter ting i components navnerommet.
  ;; Da må du inn å touche components navnerommet, så er alt hunkydory igjen.
  (css/remove-styles!)

  (render))

(render)

Fruktene av vårt arbeid er disse råflotte spinnerene.

Pacman?

La oss prøve å lage noe litt mer omfattende. Hva med en Pacman som flytter på seg?

Vi utvider components-navnerommet vårt (src/cljs_animations/components.cljs) med følgende.

;; Animasjon for munnen. Vi roterer to halvsirkler mot/fra hverandre for å simulere at pacman spiser.
(defkeyframes pacman-mouth-frames [degrees]
  {"50%" {:transform (str "rotate(" degrees "deg)")}})

;; Animasjon som flytter hele pacman, starter uten for viewport og
;; beveger seg horisontalt til høyre helt til enden av viewport.
(defkeyframes pacman-move-frames []
  {:from {:left "-20%"}
   :to {:left "100%"}})

(defstyles pacman-styles [{:keys [moving?]}]
  {:position "absolute"
   :top "50%"
   :animation (when moving?
                (str (pacman-move-frames) " 5s linear infinite"))
   ;; Her definerer vi et pseudo element.
   ;; CSS triks for å lage en halvsirkel vha av borders.
   ;; Dette er øvre halvparten av pacman.
   :&:before {:content ""
              :position "absolute"
              :display "block"
              :height 0
              :width 0
              :margin-top "-30px"
              :border-left "solid 30px yellow"
              :border-top "solid 30px yellow"
              :border-right "solid 30px transparent"
              :border-bottom "solid 30px transparent"
              :border-radius "50%"
              :animation (str (pacman-mouth-frames 43) " 0.5s ease infinite")}
   ; Nedre halvdel av pacman
   :&:after {:content ""
             :position "absolute"
             :display "block"
             :height 0
             :width 0
             :margin-top "-30px"
             :border-left "solid 30px yellow"
             :border-top "solid 30px transparent"
             :border-right "solid 30px transparent"
             :border-bottom "solid 30px yellow"
             :border-radius "50%"
             :animation (str (pacman-mouth-frames -43) " 0.5s ease infinite")}})

(q/defcomponent Pacman [params]
  [:div {:class (pacman-styles params)}])

Her har vi altså 3 animasjoner gående samtidig. 1 for hver halvdel(/halvsirkel) av pacman og en for å bevege pacman horisontalt. Vi bruker css pseudo elementer, noe som ikke er mulig vha inline styling. Styling, rendering og animasjon samlet til en (tullete) pacman komponent. Det er jo litt kult tross alt!

Er det ikke endel duplisering her? Kunne man ikke ha skrevet dette ganske mye mindre verbost i CSS?

Jo det er sant det. Spesielt to ting som gjør det vanskelig å kutte ned.

  1. CSS definisjonene bruker Clojure Maps. Disse er i utgangspunktet ikke sorterte, så man kan ikke bruke rekkefølge triks slik man ofte gjør i css verdenen.
  2. CLJSS sin defstyles bruker Clojure macro magi (genererer ClojureScript compile time). På grunn av eh… en eller annen grunn, liker ikke macroen at man gjør bruker funksjoner som konstruerer maps. Du er pent nødt til å returnere map literaler. Det begrenser de-duplisering en smule kan du si.

Pacman demo

For å se hvordan pacman ser ut lager vi ett nytt card i devcards navnerommet vårt (/devcards/cljs_animations/cards.cljs).

(defcard
  [:div {:style {:position "relative"
                 :height "100px"
                 :box-sizing "border-box"
                 :padding "20px"
                 :background "#333"
                 :overflow "hidden"}}
   (components/Pacman {:moving? true})])

Uten alt for mye om og men, er vi nå noen babysteg nærmere ett pacman spill!

Oppsummering

Etter litt oppsett av ClojureScript verktøy som Figwheel og Devcards, har vi laget noen enkle animasjoner. Vha CLJSS-biblioteket har vi laget små selvstendige komponenter som sålangt kan stå på egne ben. Styling, markup, animasjoner og applikasjonslogikk er samlokalisert i små byggeklosser. CLJSS-biblioteket viste seg å ha noen småkjipe begrensninger mtp DRY, men for disse små eksemplene kan man leve med det.

Så langt har vi ikke tatt innover oss tilstand i applikasjonen. Dette er noe vi skal kikke næremere på i neste episode.

Diskusjon

Vi diskuterer gjerne hvor enn du finner oss. Ta kontakt!

Mer fra bloggen