Slide button component (bounty) (#16259)

This commit is contained in:
Lungu Cristian 2023-06-15 14:25:52 +02:00 committed by GitHub
parent 4f4489ee51
commit d43b73b566
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 510 additions and 1 deletions

View File

@ -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))))))))

View File

@ -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)))))))

View File

@ -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)

View File

@ -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))))

View File

@ -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))))

View File

@ -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])

View File

@ -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)

View File

@ -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]

View File

@ -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]

View File

@ -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}]])

View File

@ -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}]

View File

@ -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)))