From d43b73b5661d679e01c2d6f9bb871dfab0ca5f3e Mon Sep 17 00:00:00 2001 From: Lungu Cristian Date: Thu, 15 Jun 2023 14:25:52 +0200 Subject: [PATCH] Slide button component (bounty) (#16259) --- .../buttons/slide_button/animations.cljs | 101 +++++++++++++++++ .../buttons/slide_button/component_spec.cljs | 105 ++++++++++++++++++ .../buttons/slide_button/constants.cljs | 15 +++ .../buttons/slide_button/style.cljs | 80 +++++++++++++ .../buttons/slide_button/utils.cljs | 38 +++++++ .../components/buttons/slide_button/view.cljs | 89 +++++++++++++++ src/quo2/core.cljs | 2 + src/quo2/core_spec.cljs | 1 + src/react_native/gesture.cljs | 5 +- .../quo_preview/buttons/slide_button.cljs | 67 +++++++++++ src/status_im2/contexts/quo_preview/main.cljs | 4 + src/test_helpers/component.cljs | 4 + 12 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 src/quo2/components/buttons/slide_button/animations.cljs create mode 100644 src/quo2/components/buttons/slide_button/component_spec.cljs create mode 100644 src/quo2/components/buttons/slide_button/constants.cljs create mode 100644 src/quo2/components/buttons/slide_button/style.cljs create mode 100644 src/quo2/components/buttons/slide_button/utils.cljs create mode 100644 src/quo2/components/buttons/slide_button/view.cljs create mode 100644 src/status_im2/contexts/quo_preview/buttons/slide_button.cljs diff --git a/src/quo2/components/buttons/slide_button/animations.cljs b/src/quo2/components/buttons/slide_button/animations.cljs new file mode 100644 index 0000000000..88273471e8 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/animations.cljs @@ -0,0 +1,101 @@ +(ns quo2.components.buttons.slide-button.animations + (:require + [react-native.gesture :as gesture] + [quo2.components.buttons.slide-button.utils :as utils] + [oops.core :as oops] + [react-native.reanimated :as reanimated])) + +(def ^:private extrapolation + {:extrapolateLeft "clamp" + :extrapolateRight "clamp"}) + +(defn- track-interpolation-inputs + [in-vectors track-width] + (map (partial * track-width) in-vectors)) + +;; Interpolations +(defn- track-clamp-interpolation + [track-width] + {:in [-1 0 1] + :out [track-width 0 track-width]}) + +(defn- track-cover-interpolation + [track-width thumb-size] + {:in [0 1] + :out [(/ thumb-size 2) track-width]}) + +(defn- arrow-icon-position-interpolation + [thumb-size] + {:in [0.9 1] + :out [0 (- thumb-size)]}) + +(defn- action-icon-position-interpolation + [thumb-size] + {:in [0.9 1] + :out [thumb-size 0]}) + +(defn interpolate-track + "Interpolate the position in the track + `x-pos` Track animated value + `track-width` Usable width of the track + `thumb-size` Size of the thumb + `interpolation` ` :thumb-border-radius`/`:thumb-drop-position`/`:thumb-drop-scale`/`:thumb-drop-z-index`/..." + ([x-pos track-width thumb-size interpolation] + (let [interpolations {:track-cover (track-cover-interpolation track-width thumb-size) + :track-clamp (track-clamp-interpolation track-width) + :action-icon-position (action-icon-position-interpolation thumb-size) + :arrow-icon-position (arrow-icon-position-interpolation thumb-size)} + + interpolation-values (interpolation interpolations) + output (:out interpolation-values) + input (-> (:in interpolation-values) + (track-interpolation-inputs track-width))] + (if interpolation-values + (reanimated/interpolate x-pos + input + output + extrapolation) + x-pos)))) + +;; Animations +(defn- animate-spring + [value to-value] + (reanimated/animate-shared-value-with-spring value + to-value + {:mass 1 + :damping 30 + :stiffness 400})) + +(defn- complete-animation + [sliding-complete?] + (reset! sliding-complete? true)) + +(defn- reset-track-position + [x-pos] + (animate-spring x-pos 0)) + +;; Gestures +(defn drag-gesture + [x-pos + gestures-disabled? + disabled? + track-width + sliding-complete?] + (let [gestures-enabled? (not (or disabled? @gestures-disabled?))] + (-> (gesture/gesture-pan) + (gesture/with-test-ID :slide-button-gestures) + (gesture/enabled gestures-enabled?) + (gesture/min-distance 0) + (gesture/on-update (fn [event] + (let [x-translation (oops/oget event "translationX") + clamped-x (utils/clamp-value x-translation 0 track-width) + reached-end? (>= clamped-x track-width)] + (reanimated/set-shared-value x-pos clamped-x) + (when (and reached-end? (not @sliding-complete?)) + (reset! gestures-disabled? true) + (complete-animation sliding-complete?))))) + (gesture/on-end (fn [event] + (let [x-translation (oops/oget event "translationX") + reached-end? (>= x-translation track-width)] + (when (not reached-end?) + (reset-track-position x-pos)))))))) diff --git a/src/quo2/components/buttons/slide_button/component_spec.cljs b/src/quo2/components/buttons/slide_button/component_spec.cljs new file mode 100644 index 0000000000..c72d1cd8e2 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/component_spec.cljs @@ -0,0 +1,105 @@ +(ns quo2.components.buttons.slide-button.component-spec + (:require [quo2.components.buttons.slide-button.view :as slide-button] + [quo2.components.buttons.slide-button.constants :as constants] + [quo2.components.buttons.slide-button.utils :as utils] + ["@testing-library/react-native" :as rtl] + ["react-native-gesture-handler/jest-utils" :as gestures-jest] + [reagent.core :as r] + [test-helpers.component :as h])) + +;; NOTE stolen from +;; (https://github.com/reagent-project/reagent/blob/a14faba55e373000f8f93edfcfce0d1222f7e71a/test/reagenttest/utils.cljs#LL104C7-L104C10), +;; +;; There's also a comment over there about it being +;; not "usable with production React", but no explanation why. +;; If we decide to keep it, can be moved to `test-helpers.component`. +(defn act + "Run f to trigger Reagent updates, + will return Promise which will resolve after + Reagent and React render." + [f] + (js/Promise. + (fn [resolve reject] + (try + (.then (rtl/act + #(let [p (js/Promise. (fn [resolve _reject] + (r/after-render (fn reagent-act-after-reagent-flush [] + (resolve)))))] + (f) + p)) + resolve + reject) + (catch :default e + (reject e)))))) + +(def ^:private gesture-state + {:untedermined 0 + :failed 1 + :began 2 + :cancelled 3 + :active 4 + :end 5}) + +(defn gesture-x-event + [event position] + (clj->js {:state (event gesture-state) + :translationX position})) + +(defn slide-events + [dest] + [(gesture-x-event :began 0) + (gesture-x-event :active 0) + (gesture-x-event :active dest) + (gesture-x-event :end dest)]) + +(defn get-by-gesture-test-id + [test-id] + (gestures-jest/getByGestureTestId + (str test-id))) + +(def ^:private default-props + {:on-complete identity + :track-text :test-track-text + :track-icon :face-id}) + +(h/describe "slide-button" + (h/test "render the correct text" + (h/render [slide-button/view default-props]) + (h/is-truthy (h/get-by-text :test-track-text))) + + (h/test "render the disabled button" + (h/render [slide-button/view (assoc default-props :disabled? true)]) + (let [track-mock (h/get-by-test-id :slide-button-track)] + (h/has-style track-mock {:opacity constants/disable-opacity}))) + + (h/test "render the small button" + (h/render [slide-button/view (assoc default-props :size :small)]) + (let [mock (h/get-by-test-id :slide-button-track) + small-height (:track-height constants/small-dimensions)] + (h/has-style mock {:height small-height}))) + + (h/test "render with the correct customization-color" + (h/render [slide-button/view (assoc default-props :customization-color :purple)]) + (let [track-mock (h/get-by-test-id :slide-button-track) + purple-color (utils/slider-color :track :purple)] + (h/has-style track-mock {:backgroundColor purple-color}))) + + (h/test + "calls on-complete when dragged" + (let [props (merge default-props {:on-complete (h/mock-fn)}) + slide-dest constants/default-width + gesture-events (slide-events slide-dest)] + (h/render [slide-button/view props]) + (-> (act #(gestures-jest/fireGestureHandler (get-by-gesture-test-id :slide-button-gestures) + gesture-events)) + (.then #(h/was-called (:on-complete props)))))) + + (h/test + "doesn't call on-complete if the slide was incomplete" + (let [props (merge default-props {:on-complete (h/mock-fn)}) + slide-dest (- constants/default-width 100) + gesture-events (slide-events slide-dest)] + (h/render [slide-button/view props]) + (-> (act #(gestures-jest/fireGestureHandler (get-by-gesture-test-id :slide-button-gestures) + gesture-events)) + (.then #(h/was-not-called (:on-complete props))))))) diff --git a/src/quo2/components/buttons/slide_button/constants.cljs b/src/quo2/components/buttons/slide_button/constants.cljs new file mode 100644 index 0000000000..f073c7ae98 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/constants.cljs @@ -0,0 +1,15 @@ +(ns quo2.components.buttons.slide-button.constants) + +(def track-padding 4) + +(def small-dimensions + {:track-height 40 + :thumb 32}) + +(def large-dimensions + {:track-height 48 + :thumb 40}) + +(def disable-opacity 0.3) + +(def default-width 300) diff --git a/src/quo2/components/buttons/slide_button/style.cljs b/src/quo2/components/buttons/slide_button/style.cljs new file mode 100644 index 0000000000..6aade4aea8 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/style.cljs @@ -0,0 +1,80 @@ +(ns quo2.components.buttons.slide-button.style + (:require + [quo2.components.buttons.slide-button.constants :as constants] + [quo2.components.buttons.slide-button.utils :as utils] + [react-native.reanimated :as reanimated] + [quo2.foundations.typography :as typography])) + +(def absolute-fill + {:position :absolute + :top 0 + :bottom 0 + :left 0 + :right 0}) + +(defn thumb-container + [interpolate-track thumb-size customization-color] + (reanimated/apply-animations-to-style + {:transform [{:translate-x (interpolate-track :track-clamp)}]} + {:background-color (utils/slider-color :main customization-color) + :border-radius 12 + :height thumb-size + :width thumb-size + :align-items :center + :overflow :hidden + :justify-content :center})) + +(defn arrow-icon-container + [interpolate-track] + (reanimated/apply-animations-to-style + {:transform [{:translate-x (interpolate-track :arrow-icon-position)}]} + {:flex 1 + :align-items :center + :justify-content :center})) + +(defn action-icon + [interpolate-track size] + (reanimated/apply-animations-to-style + {:transform [{:translate-x (interpolate-track :action-icon-position)}]} + {:height size + :width size + :position :absolute + :align-items :center + :left 0 + :top 0 + :flex-direction :row + :justify-content :space-around})) + +(defn track + [disabled? customization-color height] + {:align-items :flex-start + :justify-content :center + :border-radius 14 + :height height + :align-self :stretch + :padding constants/track-padding + :opacity (if disabled? 0.3 1) + :background-color (utils/slider-color :track customization-color)}) + +(defn track-cover + [interpolate-track] + (reanimated/apply-animations-to-style + {:left (interpolate-track :track-cover)} + (assoc absolute-fill :overflow :hidden))) + +(defn track-cover-text-container + [track-width] + {:position :absolute + :right 0 + :top 0 + :bottom 0 + :align-items :center + :justify-content :center + :flex-direction :row + :width track-width}) + +(defn track-text + [customization-color] + (-> typography/paragraph-1 + (merge typography/font-medium) + (assoc :color (utils/slider-color :main customization-color)))) diff --git a/src/quo2/components/buttons/slide_button/utils.cljs b/src/quo2/components/buttons/slide_button/utils.cljs new file mode 100644 index 0000000000..9da58cf740 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/utils.cljs @@ -0,0 +1,38 @@ +(ns quo2.components.buttons.slide-button.utils + (:require + [quo2.components.buttons.slide-button.constants :as constants] + [quo2.foundations.colors :as colors])) + +(defn slider-color + "- `color-key` `:main`/`:track` + - `customization-color` Customization color" + [color-key customization-color] + (let [colors-by-key {:main (colors/custom-color-by-theme customization-color 50 60) + :track (colors/custom-color-by-theme customization-color 50 60 10 10)}] + (color-key colors-by-key))) + +(defn clamp-value + [value min-value max-value] + (cond + (< value min-value) min-value + (> value max-value) max-value + :else value)) + +(defn calc-usable-track + "Calculate the track section in which the + thumb can move in. Mostly used for interpolations." + [track-width thumb-size] + (let [double-padding (* constants/track-padding 2)] + (- track-width double-padding thumb-size))) + +(defn get-dimensions + [track-width size dimension-key] + (let [default-dimensions (case size + :small constants/small-dimensions + :large constants/large-dimensions + constants/large-dimensions)] + (-> default-dimensions + (merge {:usable-track (calc-usable-track + track-width + (:thumb default-dimensions))}) + (get dimension-key)))) diff --git a/src/quo2/components/buttons/slide_button/view.cljs b/src/quo2/components/buttons/slide_button/view.cljs new file mode 100644 index 0000000000..8417ab5636 --- /dev/null +++ b/src/quo2/components/buttons/slide_button/view.cljs @@ -0,0 +1,89 @@ +(ns quo2.components.buttons.slide-button.view + (:require + [quo2.components.icon :as icon] + [quo2.foundations.colors :as colors] + [quo2.components.buttons.slide-button.style :as style] + [quo2.components.buttons.slide-button.utils :as utils] + [quo2.components.buttons.slide-button.animations :as animations] + [react-native.gesture :as gesture] + [react-native.core :as rn] + [reagent.core :as reagent] + [oops.core :as oops] + [react-native.reanimated :as reanimated] + [quo2.components.buttons.slide-button.constants :as constants])) + +(defn- f-slider + [{:keys [disabled?]}] + (let [track-width (reagent/atom nil) + sliding-complete? (reagent/atom false) + gestures-disabled? (reagent/atom disabled?) + on-track-layout (fn [evt] + (let [width (oops/oget evt "nativeEvent.layout.width")] + (reset! track-width width)))] + + (fn [{:keys [on-complete + track-text + track-icon + disabled? + customization-color + size]}] + (let [x-pos (reanimated/use-shared-value 0) + dimensions (partial utils/get-dimensions + (or @track-width constants/default-width) + size) + interpolate-track (partial animations/interpolate-track + x-pos + (dimensions :usable-track) + (dimensions :thumb))] + + (rn/use-effect (fn [] + (when @sliding-complete? + (on-complete))) + [@sliding-complete?]) + + [gesture/gesture-detector + {:gesture (animations/drag-gesture x-pos + gestures-disabled? + disabled? + (dimensions :usable-track) + sliding-complete?)} + [reanimated/view + {:test-ID :slide-button-track + :style (style/track disabled? customization-color (dimensions :track-height)) + :on-layout (when-not (some? @track-width) + on-track-layout)} + [reanimated/view {:style (style/track-cover interpolate-track)} + [rn/view {:style (style/track-cover-text-container @track-width)} + [icon/icon track-icon + {:color (utils/slider-color :main customization-color) + :size 20}] + [rn/view {:width 4}] + [rn/text {:style (style/track-text customization-color)} track-text]]] + [reanimated/view + {:style (style/thumb-container interpolate-track + (dimensions :thumb) + customization-color)} + [reanimated/view {:style (style/arrow-icon-container interpolate-track)} + [icon/icon :arrow-right + {:color colors/white + :size 20}]] + [reanimated/view + {:style (style/action-icon interpolate-track + (dimensions :thumb))} + [icon/icon track-icon + {:color colors/white + :size 20}]]]]])))) + +(defn view + "Options + - `on-complete` Callback called when the sliding is complete + - `disabled?` Boolean that disables the button + (_and gestures_) + - `size` `:small`/`:large` + - `track-text` Text that is shown on the track + - `track-icon` Key of the icon shown on the track + (e.g. `:face-id`) + - `customization-color` Customization color + " + [props] + [:f> f-slider props]) diff --git a/src/quo2/core.cljs b/src/quo2/core.cljs index b68988be3e..e5092ee9d0 100644 --- a/src/quo2/core.cljs +++ b/src/quo2/core.cljs @@ -11,6 +11,7 @@ quo2.components.buttons.button quo2.components.buttons.dynamic-button quo2.components.buttons.predictive-keyboard.view + quo2.components.buttons.slide-button.view quo2.components.colors.color-picker.view quo2.components.community.community-card-view quo2.components.community.community-list-view @@ -130,6 +131,7 @@ (def button quo2.components.buttons.button/button) (def dynamic-button quo2.components.buttons.dynamic-button/dynamic-button) (def predictive-keyboard quo2.components.buttons.predictive-keyboard.view/view) +(def slide-button quo2.components.buttons.slide-button.view/view) ;;;; CARDS (def small-option-card quo2.components.onboarding.small-option-card.view/small-option-card) diff --git a/src/quo2/core_spec.cljs b/src/quo2/core_spec.cljs index 8abc127984..43d857e540 100644 --- a/src/quo2/core_spec.cljs +++ b/src/quo2/core_spec.cljs @@ -4,6 +4,7 @@ [quo2.components.banners.banner.component-spec] [quo2.components.buttons.--tests--.buttons-component-spec] [quo2.components.buttons.predictive-keyboard.component-spec] + [quo2.components.buttons.slide-button.component-spec] [quo2.components.colors.color-picker.component-spec] [quo2.components.counter.--tests--.counter-component-spec] [quo2.components.counter.step.component-spec] diff --git a/src/react_native/gesture.cljs b/src/react_native/gesture.cljs index e755ef7e7b..2fa1efd847 100644 --- a/src/react_native/gesture.cljs +++ b/src/react_native/gesture.cljs @@ -33,12 +33,16 @@ (defn max-pointers [gesture count] (.maxPointers ^js gesture count)) +(defn min-distance [gesture dist] (.minDistance ^js gesture dist)) + (defn number-of-taps [gesture count] (.numberOfTaps ^js gesture count)) (defn enabled [gesture enabled?] (.enabled ^js gesture enabled?)) (defn average-touches [gesture average-touches?] (.averageTouches ^js gesture average-touches?)) +(defn with-test-ID [gesture test-ID] (.withTestId ^js gesture (str test-ID))) + (defn simultaneous ([g1 g2] (.Simultaneous ^js Gesture g1 g2)) ([g1 g2 g3] (.Simultaneous ^js Gesture g1 g2 g3))) @@ -77,7 +81,6 @@ (def scroll-view (reagent/adapt-react-class ScrollView)) - ;;; Custom gesture section-list (defn- flatten-sections [sections] diff --git a/src/status_im2/contexts/quo_preview/buttons/slide_button.cljs b/src/status_im2/contexts/quo_preview/buttons/slide_button.cljs new file mode 100644 index 0000000000..48b29daed0 --- /dev/null +++ b/src/status_im2/contexts/quo_preview/buttons/slide_button.cljs @@ -0,0 +1,67 @@ +(ns status-im2.contexts.quo-preview.buttons.slide-button + (:require [quo2.core :as quo] + [quo2.foundations.colors :as colors] + [react-native.core :as rn] + [reagent.core :as reagent] + [status-im2.contexts.quo-preview.preview :as preview])) + +(def descriptor + [{:label "Size:" + :key :size + :type :select + :options [{:key :large + :value "Large"} + {:key :small + :value "Small"}]} + {:label "Disabled:" + :key :disabled? + :type :boolean} + {:label "Custom Color" + :key :color + :type :select + :options (map (fn [color] + (let [key (get color :name)] + {:key key :value key})) + (quo/picker-colors))}]) + +(defn cool-preview + [] + (let [state (reagent/atom {:disabled? false + :color :blue + :size :large}) + color (reagent/cursor state [:color]) + disabled? (reagent/cursor state [:disabled?]) + size (reagent/cursor state [:size]) + complete? (reagent/atom false)] + (fn [] + [rn/touchable-without-feedback {:on-press rn/dismiss-keyboard!} + [rn/view {:padding-bottom 150} + [preview/customizer state descriptor] + [rn/view + {:padding-vertical 60 + :padding-horizontal 40 + :align-items :center} + (if (not @complete?) + [quo/slide-button + {:track-text "We gotta slide" + :track-icon :face-id + :customization-color @color + :size @size + :disabled? @disabled? + :on-complete (fn [] + (js/setTimeout (fn [] (reset! complete? true)) + 1000) + (js/alert "I don't wanna slide anymore"))}] + [quo/button {:on-press (fn [] (reset! complete? false))} + "Try again"])]]]))) + +(defn preview-slide-button + [] + [rn/view + {:background-color (colors/theme-colors colors/white colors/neutral-90) + :flex 1} + [rn/flat-list + {:flex 1 + :keyboard-should-persist-taps :always + :header [cool-preview] + :key-fn str}]]) diff --git a/src/status_im2/contexts/quo_preview/main.cljs b/src/status_im2/contexts/quo_preview/main.cljs index 2ee98f6ff5..32d9618cb0 100644 --- a/src/status_im2/contexts/quo_preview/main.cljs +++ b/src/status_im2/contexts/quo_preview/main.cljs @@ -17,6 +17,7 @@ [status-im2.contexts.quo-preview.avatars.wallet-user-avatar :as wallet-user-avatar] [status-im2.contexts.quo-preview.banners.banner :as banner] [status-im2.contexts.quo-preview.buttons.button :as button] + [status-im2.contexts.quo-preview.buttons.slide-button :as slide-button] [status-im2.contexts.quo-preview.buttons.dynamic-button :as dynamic-button] [status-im2.contexts.quo-preview.buttons.predictive-keyboard :as predictive-keyboard] [status-im2.contexts.quo-preview.code.snippet :as code-snippet] @@ -130,6 +131,9 @@ {:name :dynamic-button :options {:topBar {:visible true}} :component dynamic-button/preview-dynamic-button} + {:name :slide-button + :options {:topBar {:visible true}} + :component slide-button/preview-slide-button} {:name :predictive-keyboard :options {:topBar {:visible true}} :component predictive-keyboard/preview-predictive-keyboard}] diff --git a/src/test_helpers/component.cljs b/src/test_helpers/component.cljs index e202cab9b8..e4f719b220 100644 --- a/src/test_helpers/component.cljs +++ b/src/test_helpers/component.cljs @@ -211,3 +211,7 @@ (defn was-not-called [mock] (was-called-times mock 0)) + +(defn has-style + [mock styles] + (.toHaveStyle (js/expect mock) (clj->js styles)))