Scrollable tags (#14182)

* refactored scrollable-tags to share the same logic with scrollable-tabs

* refactored tabs component to support scrollable-behaviour
This commit is contained in:
John Ngei 2023-01-08 22:40:59 +03:00 committed by GitHub
parent 4cdc166e41
commit 1cdcd298b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 520 additions and 240 deletions

View File

@ -46,12 +46,12 @@
^{:key id}
[rn/view {:margin-right 8}
[tag/tag
{:id id
:size 24
:label tag-label
:type :emoji
:labelled true
:resource resource}]])])
{:id id
:size 24
:label tag-label
:type :emoji
:labelled? true
:resource resource}]])])
(defn community-title
[{:keys [title description size] :or {size :small}}]

View File

@ -28,28 +28,6 @@
:background-color (colors/theme-colors colors/neutral-5 colors/neutral-95)}}
[notification-dot]])
(defn tabs
[{:keys [default-active on-change style]}]
(let [active-tab-id (reagent/atom default-active)]
(fn [{:keys [data size] :or {size default-tab-size}}]
[rn/view (merge {:flex-direction :row} style)
(doall
(for [{:keys [label id notification-dot? accessibility-label]} data]
^{:key id}
[rn/view {:style {:margin-right (if (= size default-tab-size) 12 8)}}
(when notification-dot?
[indicator])
[tab/tab
{:id id
:size size
:accessibility-label accessibility-label
:active (= id @active-tab-id)
:on-press (fn []
(reset! active-tab-id id)
(when on-change
(on-change id)))}
label]]))])))
(defn- calculate-fade-end-percentage
[{:keys [offset-x content-width layout-width max-fade-percentage]}]
(let [fade-percentage (max max-fade-percentage
@ -60,30 +38,35 @@
0.99
(utils.number/naive-round fade-percentage 2))))
(defn scrollable-tabs
"Just like the component `tabs`, displays horizontally scrollable tabs with
extra options to control if/how the end of the scroll view fades.
Tabs are rendered using ReactNative's FlatList, which offers the convenient
`scrollToIndex` method. FlatList accepts VirtualizedList and ScrollView props,
and so does this component.
Usage:
[tabs/scrollable-tabs
{:scroll-on-press? true
:fade-end? true
:on-change #(...)
:default-active :tab-a
:data [{:id :tab-a :label \"Tab A\"}
{:id :tab-b :label \"Tab B\"}]}]]
(defn tabs
"Usage:
{:type :icon/:emoji/:label
:component tag/tab
:size 32/24
:on-press fn
:blurred? true/false
:labelled? true/false
:disabled? true/false
:scrollable? false
:scroll-on-press? true
:fade-end? true
:on-change fn
:default-active tag-id
:data [{:id :label \"\" :resource \"url\"}
{:id :label \"\" :resource \"url\"}]}
Opts:
- `size` number
- `scroll-on-press?` When non-nil, clicking on a tab centers it the middle
- `component` this is to determine which component is to be rendered since the
logic in this view is shared between tab and tag component
- `blurred` boolean: use to determine border color if the background is blurred
- `type` can be icon or emoji with or without a tag label
- `labelled` boolean: is true if tag has label else false
- `size` number
- `scroll-on-press?` When non-nil, clicking on a tag centers it the middle
(with animation enabled).
- `fade-end?` When non-nil, causes the end of the scrollable view to fade out.
- `fade-end-percentage` Percentage where fading starts relative to the total
- `fade-end?` When non-nil, causes the end of the scrollable view to fade out.
- `fade-end-percentage` Percentage where fading starts relative to the total
layout width of the `flat-list` data."
[{:keys [default-active fade-end-percentage]
:or {fade-end-percentage 0.8}}]
(let [active-tab-id (reagent/atom default-active)
@ -97,99 +80,123 @@
on-scroll
scroll-event-throttle
scroll-on-press?
scrollable?
style
size
blur?
override-theme]
:or {fade-end-percentage fade-end-percentage
fade-end? false
scroll-event-throttle 64
scrollable? false
scroll-on-press? false
size default-tab-size}
:as props}]
(let [maybe-mask-wrapper (if fade-end?
[masked-view/masked-view
{:mask-element
(reagent/as-element
[linear-gradient/linear-gradient
{:colors [:black :transparent]
:locations [(get @fading :fade-end-percentage) 1]
:start {:x 0 :y 0}
:end {:x 1 :y 0}
:pointer-events :none
:style {:width "100%"
:height "100%"}}])}]
[:<>])]
(conj
maybe-mask-wrapper
[rn/flat-list
(merge
(dissoc props
:default-active
:fade-end-percentage
:fade-end?
:on-change
:scroll-on-press?
:size)
(when scroll-on-press?
{:initial-scroll-index (utils.collection/first-index #(= @active-tab-id (:id %)) data)})
{:ref #(reset! flat-list-ref %)
:extra-data (str @active-tab-id)
:horizontal true
:scroll-event-throttle scroll-event-throttle
:shows-horizontal-scroll-indicator false
:data data
:key-fn (comp str :id)
:on-scroll-to-index-failed identity
:on-scroll (fn [^js e]
(when fade-end?
(let [offset-x (oget
e
"nativeEvent.contentOffset.x")
content-width (oget
e
"nativeEvent.contentSize.width")
layout-width
(oget e "nativeEvent.layoutMeasurement.width")
new-percentage
(calculate-fade-end-percentage
{:offset-x offset-x
:content-width content-width
:layout-width layout-width
:max-fade-percentage fade-end-percentage})]
;; Avoid unnecessary re-rendering.
(when (not= new-percentage
(get @fading :fade-end-percentage))
(swap! fading assoc
:fade-end-percentage
new-percentage))))
(when on-scroll
(on-scroll e)))
:render-fn (fn [{:keys [id label]} index]
[rn/view
{:style {:margin-right (if (= size default-tab-size)
12
8)
:padding-right (when (= index
(dec (count data)))
(get-in props
[:style
:padding-left]))}}
[tab/tab
{:id id
:size size
:override-theme override-theme
:blur? blur?
:active (= id @active-tab-id)
:on-press (fn [id]
(reset! active-tab-id id)
(when scroll-on-press?
(.scrollToIndex ^js
@flat-list-ref
#js
{:animated true
:index index
:viewPosition
0.5}))
(when on-change
(on-change id)))}
label]])})])))))
(if scrollable?
(let [maybe-mask-wrapper (if fade-end?
[masked-view/masked-view
{:mask-element
(reagent/as-element
[linear-gradient/linear-gradient
{:colors [:black :transparent]
:locations [(get @fading :fade-end-percentage) 1]
:start {:x 0 :y 0}
:end {:x 1 :y 0}
:pointer-events :none
:style {:width "100%"
:height "100%"}}])}]
[:<>])]
(conj
maybe-mask-wrapper
[rn/flat-list
(merge
(dissoc props
:default-active
:fade-end-percentage
:fade-end?
:on-change
:scroll-on-press?
:size)
(when scroll-on-press?
{:initial-scroll-index (utils.collection/first-index #(= @active-tab-id (:id %)) data)})
{:ref #(reset! flat-list-ref %)
:extra-data (str @active-tab-id)
:horizontal true
:scroll-event-throttle scroll-event-throttle
:shows-horizontal-scroll-indicator false
:data data
:key-fn (comp str :id)
:on-scroll-to-index-failed identity
:on-scroll (fn [^js e]
(when fade-end?
(let [offset-x (oget
e
"nativeEvent.contentOffset.x")
content-width
(oget
e
"nativeEvent.contentSize.width")
layout-width
(oget e
"nativeEvent.layoutMeasurement.width")
new-percentage
(calculate-fade-end-percentage
{:offset-x offset-x
:content-width content-width
:layout-width layout-width
:max-fade-percentage fade-end-percentage})]
;; Avoid unnecessary re-rendering.
(when (not= new-percentage
(get @fading :fade-end-percentage))
(swap! fading assoc
:fade-end-percentage
new-percentage))))
(when on-scroll
(on-scroll e)))
:render-fn (fn [{:keys [id label]} index]
[rn/view
{:style {:margin-right (if (= size default-tab-size)
12
8)
:padding-right (when (= index
(dec (count data)))
(get-in props
[:style
:padding-left]))}}
[tab/tab
{:id id
:size size
:override-theme override-theme
:blur? blur?
:active (= id @active-tab-id)
:on-press (fn [id]
(reset! active-tab-id id)
(when scroll-on-press?
(.scrollToIndex
^js
@flat-list-ref
#js
{:animated true
:index index
:viewPosition
0.5}))
(when on-change
(on-change id)))}
label]])})]))
[rn/view (merge {:flex-direction :row} style)
(doall
(for [{:keys [label id notification-dot? accessibility-label]} data]
^{:key id}
[rn/view {:style {:margin-right (if (= size default-tab-size) 12 8)}}
(when notification-dot?
[indicator])
[tab/tab
{:id id
:size size
:accessibility-label accessibility-label
:active (= id @active-tab-id)
:on-press (fn []
(reset! active-tab-id id)
(when on-change
(on-change id)))}
label]]))]))))

View File

@ -16,25 +16,21 @@
{:width size})))
(defn base-tag
"opts
{:type :icon/:emoji/:label/:permission
:size 32/24}
:labelled true"
[_]
(fn [{:keys [id size disabled border-color border-width background-color on-press
accessibility-label label type]
(fn [{:keys [id size disabled? border-color border-width background-color on-press
accessibility-label labelled? type]
:or {size 32}} children]
[rn/touchable-without-feedback
(merge {:disabled disabled
(merge {:disabled disabled?
:accessibility-label accessibility-label}
(when on-press
{:on-press #(on-press id)}))
[rn/view
{:style (merge (style-container size
disabled
disabled?
border-color
border-width
background-color
label
labelled?
type))}
children]]))

View File

@ -68,26 +68,40 @@
label])])
(defn tag
"opts
{:type :icon/:emoji/:label
:size 32/24
:on-press fn
:blurred? true/false
:resource icon/image
:labelled? true/false
:disabled? true/false}
opts
- `blurred` boolean: use to determine border color if the background is blurred
- `type` can be icon or emoji with or without a tag label
- `labelled` boolean: is true if tag has label else false"
[_ _]
(fn [{:keys [id on-press disabled size resource active accessibility-label
label type labelled blurred icon-color]
(fn [{:keys [id on-press disabled? size resource active accessibility-label
label type labelled? blurred? icon-color]
:or {size 32}}]
(let [state (cond disabled :disabled
active :active
:else :default)
(let [state (cond disabled? :disabled
active :active
:else :default)
{:keys [border-color blurred-border-color text-color]}
(get-in themes [(theme/get-theme) state])]
[base-tag/base-tag
{:id id
:size size
:border-width 1
:border-color (if blurred
blurred-border-color
border-color)
:on-press on-press
:accessibility-label accessibility-label
:disabled disabled
:type type
:label label}
[tag-resources size type resource icon-color label text-color labelled]])))
[rn/view {:style {:align-items :center}}
[base-tag/base-tag
{:id id
:size size
:border-width 1
:border-color (if blurred?
blurred-border-color
border-color)
:on-press on-press
:accessibility-label accessibility-label
:disabled? disabled?
:type type
:labelled? (if (= type :label) true labelled?)}
[tag-resources size type resource icon-color label text-color labelled?]]])))

View File

@ -1,29 +1,171 @@
(ns quo2.components.tags.tags
(:require [quo2.components.tags.tag :as tag]
[react-native.core :as rn]
[reagent.core :as reagent]))
(:require [reagent.core :as reagent]
[quo.react-native :as rn]
[oops.core :refer [oget]]
[status-im.ui.components.react :as react]
[status-im.utils.core :as utils]
[quo2.components.tags.tag :as tag]
[utils.number :as number-utils]))
(def default-tab-size 32)
(defn calculate-fade-end-percentage
[{:keys [offset-x content-width layout-width max-fade-percentage]}]
(let [fade-percentage (max max-fade-percentage
(/ (+ layout-width offset-x)
content-width))]
;; Truncate to avoid unnecessary rendering.
(if (> fade-percentage 0.99)
0.99
(number-utils/naive-round fade-percentage 2))))
(defn tags
[{:keys [default-active on-change]}]
(let [active-tab-id (reagent/atom default-active)]
(fn [{:keys [data size type labelled disabled blurred icon-color] :or {size 32}}]
(let [active-id @active-tab-id]
[rn/view {:flex-direction :row}
(for [{:keys [tag-label id resource]} data]
^{:key id}
[rn/view {:margin-right 8}
[tag/tag
(merge {:id id
:size size
:type type
:label (if labelled tag-label (when (= type :label) tag-label))
:active (= id active-id)
:disabled disabled
:blurred blurred
:icon-color icon-color
:labelled (if (= type :label) true labelled)
:resource (if (= type :icon)
:i/placeholder
resource)
:on-press #(do (reset! active-tab-id %)
(when on-change (on-change %)))})]])]))))
"Usage:
{:type :icon/:emoji/:label
:component tag/tab
:size 32/24
:on-press fn
:blurred? true/false
:labelled? true/false
:disabled? true/false
:scroll-on-press? true
:scrollable? false
:fade-end? true
:on-change fn
:default-active tag-id
:data [{:id :label \"\" :resource \"url\"}
{:id :label \"\" :resource \"url\"}]}
Opts:
- `component` this is to determine which component is to be rendered since the
logic in this view is shared between tab and tag component
- `blurred` boolean: use to determine border color if the background is blurred
- `type` can be icon or emoji with or without a tag label
- `labelled` boolean: is true if tag has label else false
- `size` number
- `scroll-on-press?` When non-nil, clicking on a tag centers it the middle
(with animation enabled).
- `fade-end?` When non-nil, causes the end of the scrollable view to fade out.
- `fade-end-percentage` Percentage where fading starts relative to the total
layout width of the `flat-list` data."
[{:keys [default-active fade-end-percentage]
:or {fade-end-percentage 0.8}}]
(let [active-tab-id (reagent/atom default-active)
fading (reagent/atom {:fade-end-percentage fade-end-percentage})
flat-list-ref (atom nil)]
(fn
[{:keys [data
fade-end-percentage
fade-end?
on-change
on-scroll
scroll-event-throttle
scrollable?
scroll-on-press?
size
type
labelled?
disabled?
blurred?
icon-color]
:or {fade-end-percentage fade-end-percentage
fade-end? false
scroll-event-throttle 64
scrollable? false
scroll-on-press? false
size default-tab-size}
:as props}]
(let [maybe-mask-wrapper (if fade-end?
[react/masked-view
{:mask-element (reagent/as-element
[react/linear-gradient
{:colors [:black :transparent]
:locations [(get @fading :fade-end-percentage)
1]
:start {:x 0 :y 0}
:end {:x 1 :y 0}
:pointer-events :none
:style {:width "100%"
:height "100%"}}])}]
[:<>])]
(if scrollable?
(conj
maybe-mask-wrapper
[rn/flat-list
(merge (dissoc props
:default-active
:fade-end-percentage
:fade-end?
:on-change
:scroll-on-press?
:size)
(when scroll-on-press?
{:initial-scroll-index (utils/first-index #(= @active-tab-id (:id %)) data)})
{:ref #(reset! flat-list-ref %)
:extra-data (str @active-tab-id)
:horizontal true
:scroll-event-throttle scroll-event-throttle
:shows-horizontal-scroll-indicator false
:data data
:key-fn (comp str :id)
:on-scroll (fn [^js e]
(when fade-end?
(let [offset-x (oget e "nativeEvent.contentOffset.x")
content-width (oget e "nativeEvent.contentSize.width")
layout-width (oget e "nativeEvent.layoutMeasurement.width")
new-percentage (calculate-fade-end-percentage
{:offset-x offset-x
:content-width content-width
:layout-width layout-width
:max-fade-percentage fade-end-percentage})]
;; Avoid unnecessary re-rendering.
(when (not= new-percentage (get @fading :fade-end-percentage))
(swap! fading assoc :fade-end-percentage new-percentage))))
(when on-scroll
(on-scroll e)))
:render-fn (fn [{:keys [id label resource]} index]
[rn/view
{:style {:margin-right (if (= size default-tab-size) 12 8)
:padding-right (when (= index (dec (count data)))
(get-in props [:style :padding-left]))}}
[tag/tag
{:id id
:size size
:active (= id @active-tab-id)
:resource resource
:blurred? blurred?
:icon-color icon-color
:disabled? disabled?
:label label
:type type
:labelled? labelled?
:on-press (fn [id]
(reset! active-tab-id id)
(when scroll-on-press?
(.scrollToIndex @flat-list-ref
#js
{:animated true
:index index
:viewPosition 0.5}))
(when on-change
(on-change id)))}
label]])})])
[rn/view {:style {:flex-direction :row}}
(for [{:keys [label id resource]} data]
^{:key id}
[rn/view {:style {:margin-right 8}}
[tag/tag
(merge {:id id
:size size
:type type
:label (if labelled? label (when (= type :label) label))
:active (= id active-tab-id)
:disabled? disabled?
:blurred? blurred?
:icon-color icon-color
:labelled? (if (= type :label) true labelled?)
:resource (if (= type :icon)
:i/placeholder
resource)
:on-press #(do (reset! active-tab-id %)
(when on-change (on-change %)))})]])])))))

View File

@ -78,7 +78,6 @@
(def audio-tag quo2.components.tags.context-tags/audio-tag)
(def community-tag quo2.components.tags.context-tags/community-tag)
(def tabs quo2.components.tabs.tabs/tabs)
(def scrollable-tabs quo2.components.tabs.tabs/scrollable-tabs)
(def account-selector quo2.components.tabs.account-selector/account-selector)
(def floating-shell-button quo2.components.navigation.floating-shell-button/floating-shell-button)
(def status-tag quo2.components.tags.status-tags/status-tag)

View File

@ -42,8 +42,9 @@
(defn tabs
[]
(let [filter-type (rf/sub [:activity-center/filter-type])]
[quo/scrollable-tabs
[quo/tabs
{:size 32
:scrollable? true
:blur? true
:override-theme :dark
:style style/tabs

View File

@ -12,11 +12,15 @@
:options [{:key 32
:value "32"}
{:key 24
:value "24"}]}])
:value "24"}]}
{:label "Scrollable:"
:key :scrollable?
:type :boolean}])
(defn cool-preview
[]
(let [state (reagent/atom {:size 32})]
(let [state (reagent/atom {:size 32
:scrollable? false})]
(fn []
[rn/touchable-without-feedback {:on-press rn/dismiss-keyboard!}
[rn/view {:padding-bottom 150}
@ -33,7 +37,11 @@
{:id 2 :label "Tab 2"}
{:id 3 :label "Tab 3"}
{:id 4 :label "Tab 4"}]
:on-change #(println "Active tab" %)})]]]])))
:on-change #(println "Active tab" %)}
(when (:scrollable? @state)
{:scroll-on-press? true
:fade-end-percentage 0.4
:fade-end? true}))]]]])))
(defn preview-tabs
[]

View File

@ -0,0 +1,98 @@
(ns status-im2.contexts.quo-preview.tags.tag
(:require [quo.react-native :as rn]
[quo.previews.preview :as preview]
[quo2.foundations.colors :as colors]
[quo2.components.tags.tag :as tag]
[status-im.ui.components.react :as react]
[status-im.react-native.resources :as resources]
[reagent.core :as reagent]))
(def descriptor
[{:label "Size:"
:key :size
:type :select
:options [{:key 32
:value "32"}
{:key 24
:value "24"}]}
{:label "Type:"
:key :type
:type :select
:options [{:key :emoji
:value "Emoji"}
{:key :icon
:value "Icons"}
{:key :label
:value "Label"}]}
{:label "Labelled:"
:key :labelled?
:type :boolean}
{:label "Disabled:"
:key :disabled?
:type :boolean}
{:label "Blurred background:"
:key :blurred?
:type :boolean}])
(defn cool-preview
[]
(let [state (reagent/atom {:size 32
:labelled? true
:type :emoji})]
(fn []
[rn/touchable-without-feedback {:on-press rn/dismiss-keyboard!}
[rn/view
{:style {:padding-bottom 150
:padding-top 60}}
[rn/view {:flex 1}
[preview/customizer state descriptor]]
[rn/view
{:style {:flex 1
:justify-content :center
:top 60
:padding-horizontal 16}}
(when (:blurred? @state)
[rn/view
{:style {:flex 1
:height 100}}
[react/image
{:source (resources/get-image :community-cover)
:style {:flex 1
:width "100%"
:border-radius 16}}]
[react/blur-view
{:flex 1
:style {:border-radius 16
:height 100
:position :absolute
:left 0
:right 0}
:blur-amount 20
:overlay-color (colors/theme-colors
colors/white-opa-70
colors/neutral-80-opa-80)}]])
[rn/view
{:style {:position :absolute
:align-self :center}}
[tag/tag
(merge @state
{:id 1
:label "Tag"
:labelled? (if (= (:type @state) :label)
true
(:labelled? @state))
:resource (if (= :emoji (:type @state))
(resources/get-image :music)
:main-icons2/placeholder)})]]]]])))
(defn preview-tag
[]
[rn/view
{:flex 1
:background-color (colors/theme-colors
colors/white
colors/neutral-90)}
[rn/flat-list
{:flex 1
:keyboardShouldPersistTaps :always
:header [cool-preview]
:key-fn str}]])

View File

@ -24,70 +24,85 @@
:value "Icons"}
{:key :label
:value "Label"}]}
{:label "Scrollable:"
:key :scrollable?
:type :boolean}
{:label "Fade Out:"
:key :fade-end-percentage
:type :select
:options [{:key 1
:value "1%"}
{:key 0.4
:value "0.4%"}]}
{:label "Labelled:"
:key :labelled
:key :labelled?
:type :boolean}
{:label "Disabled:"
:key :disabled
:key :disabled?
:type :boolean}
{:label "Blurred background:"
:key :blurred
:key :blurred?
:type :boolean}])
(defn cool-preview
[]
(let [state (reagent/atom {:size 32
:labelled true
:type :emoji})]
(let [state (reagent/atom {:size 32
:labelled? true
:type :emoji
:fade-end-percentage 0.4
:scrollable? false})]
(fn []
[rn/touchable-without-feedback {:on-press rn/dismiss-keyboard!}
[rn/view
{:padding-bottom 150
:padding-top 60}
[rn/view {:flex 1}
{:style {:padding-bottom 150
:padding-top 60}}
[rn/view {:style {:flex 1}}
[preview/customizer state descriptor]]
[rn/view
{:flex 1
:justify-content :center
:top 60}
(when (:blurred @state)
[rn/view {:flex 1}
[react/view
{:flex-direction :row
:height 100}
[react/image
{:source (resources/get-image :community-cover)
:style {:flex 1
:height 100
:border-radius 16}}]]
[react/view
{:flex-direction :row
:height 100
:position :absolute
:left 0
:right 0}
[react/blur-view
{:flex 1
:style {:border-radius 16
:height 100}
:blur-amount 40
:overlay-color (colors/theme-colors
colors/white-opa-70
colors/neutral-80-opa-80)}]]])
[rn/scroll-view
{:justify-content :center
:align-items :center
:position :absolute
:padding-horizontal 10}
{:style {:flex 1
:justify-content :center
:top 60
:padding-horizontal 16}}
(when (:blurred? @state)
[rn/view
{:align-items :center
:height 100
:border-radius 16}
[react/image
{:source (resources/get-image :community-cover)
:style {:flex 1
:width "100%"
:border-radius 16}}]
[react/blur-view
{:flex 1
:style {:border-radius 16
:height 100
:position :absolute
:left 0
:right 0}
:blur-amount 20
:overlay-color (colors/theme-colors
colors/white-opa-70
colors/neutral-80-opa-80)}]])
[rn/view
{:style {:position :absolute
:align-self :center}}
[tags/tags
(merge
@state
{:default-active 1
:data [{:id 1 :tag-label "Music" :resource (resources/get-image :music)}
{:id 2 :tag-label "Lifestyle" :resource (resources/get-image :lifestyle)}
{:id 3
:tag-label "Podcasts"
:resource (resources/get-image :podcasts)}]})]]]]])))
:component :tags
:labelled? (if (= :label type) true (:labelled? @state))
:resource (when (= type :icon)
:main-icons2/placeholder)
:data [{:id 1 :label "Music" :resource (resources/get-image :music)}
{:id 2 :label "Lifestyle" :resource (resources/get-image :lifestyle)}
{:id 2 :label "Podcasts" :resource (resources/get-image :podcasts)}
{:id 2 :label "Music" :resource (resources/get-image :music)}
{:id 3 :label "Lifestyle" :resource (resources/get-image :lifestyle)}]}
(when (:scrollable? @state)
{:scroll-on-press? true
:fade-end? true}))]]]]])))
(defn preview-tags
[]
[rn/view