fix(avatars) - Blinking all over the app and improvement in UX (#21782)

* Change multiple uses of `merge` to `assoc` in flat-list wrapper

* Fix fast-image wrapper to avoid blinking

* merge -> assoc usage in chat-item component

* Fix avatar not being updated when the user changes their username

* Fix scalable avatar issues

- On Android, the border was inconsistent depending on the theme.
- Fix animation, simplify the calculation, and now it matches designs

* Avoid blinking by using rn/memo
This commit is contained in:
Ulises Manuel 2025-01-08 18:16:19 -06:00 committed by GitHub
parent b88bbda3c5
commit 32a3f85694
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 150 additions and 138 deletions

View File

@ -1,40 +1,79 @@
(ns react-native.fast-image
(:require
["react-native-fast-image" :as FastImage]
[clojure.string :as string]
[react-native.core :as rn]
[reagent.core :as reagent]))
[reagent.core :as reagent]
[utils.transforms :as transforms]))
(def fast-image-class (reagent/adapt-react-class ^js FastImage))
(defn- build-source
[source]
(if (string? source)
{:uri source
:priority :high}
source))
(defn placeholder
[style child]
[rn/view {:style (merge style {:flex 1 :justify-content :center :align-items :center})}
child])
(defn- remove-port
[source]
(cond
(string? source) (string/replace-first source #":\d+" "")
(:uri source) (some-> source
:uri
(string/replace-first #":\d+" ""))
:else source))
(defn fast-image
(defn- placeholder
[{:keys [style fallback-content error? loaded?]}]
[rn/view
{:style (assoc style
:flex 1
:justify-content :center
:align-items :center)}
(cond
(and error? fallback-content) fallback-content
error? [rn/text "X"]
(not loaded?) [rn/activity-indicator {:animating true}])])
;; We cannot use hooks since `reactify-component` seems to ignore the functional compiler
(defn- internal-fast-image
[_]
(let [loaded? (reagent/atom false)
error? (reagent/atom false)]
(fn [{:keys [source fallback-content] :as props}]
[fast-image-class
(merge
props
{:source (if (string? source)
{:uri source
:priority :high}
source)
:on-error (fn [e]
(when-let [on-error (:on-error props)]
(on-error e))
(reset! error? true))
:on-load (fn [e]
(when-let [on-load (:on-load props)]
(on-load e))
(reset! loaded? true)
(reset! error? false))})
(let [loaded? (reagent/atom false)
error? (reagent/atom false)
on-image-error (fn [event on-error]
(when (fn? on-error) (on-error event))
(reset! error? true))
on-image-loaded (fn [event on-load]
(when (fn? on-load) (on-load event))
(reset! loaded? true)
(reset! error? false))]
(fn [{:keys [source fallback-content on-error on-load] :as props}]
[:> FastImage
(assoc props
:source (build-source source)
:on-error #(on-image-error % on-error)
:on-load #(on-image-loaded % on-load))
(when (or @error? (not @loaded?))
[placeholder (:style props)
(if @error?
(or fallback-content [rn/text "X"])
(when-not @loaded?
[rn/activity-indicator {:animating true}]))])])))
[placeholder
{:style (js->clj (:style props))
:fallback-content fallback-content
:error? @error?
:loaded? @loaded?}])])))
(defn- compare-props
[old-props new-props]
(let [old-props-clj (transforms/js->clj old-props)
new-props-clj (transforms/js->clj new-props)
old-source (some-> old-props-clj
:source
remove-port)
new-source (some-> new-props-clj
:source
remove-port)]
(and (= old-source new-source)
(= (dissoc old-props-clj :source) (dissoc new-props-clj :source)))))
(def fast-image
(-> internal-fast-image
(reagent/reactify-component)
(rn/memo compare-props)
(reagent/adapt-react-class)))

View File

@ -23,19 +23,22 @@
(when f
(f data index))))
(defn dissoc-custom-props
[props]
(dissoc props :data :header :footer :empty-component :separator :render-fn :key-fn :on-drag-end-fn))
(defn base-list-props
[{:keys [key-fn render-fn empty-component header footer separator data render-data on-drag-end-fn]
[{:keys [key-fn data render-fn empty-component header footer separator render-data on-drag-end-fn]
:as props}]
(merge
{:data (to-array data)}
(when key-fn {:keyExtractor (wrap-key-fn key-fn)})
(when render-fn {:renderItem (wrap-render-fn render-fn render-data)})
(when separator {:ItemSeparatorComponent (fn [] (reagent/as-element separator))})
(when empty-component {:ListEmptyComponent (fn [] (reagent/as-element empty-component))})
(when header {:ListHeaderComponent (reagent/as-element header)})
(when footer {:ListFooterComponent (reagent/as-element footer)})
(when on-drag-end-fn {:onDragEnd (wrap-on-drag-end-fn on-drag-end-fn)})
(dissoc props :data :header :footer :empty-component :separator :render-fn :key-fn :on-drag-end-fn)))
(cond-> {:data (to-array data)}
key-fn (assoc :keyExtractor (wrap-key-fn key-fn))
render-fn (assoc :renderItem (wrap-render-fn render-fn render-data))
separator (assoc :ItemSeparatorComponent (fn [] (reagent/as-element separator)))
empty-component (assoc :ListEmptyComponent (fn [] (reagent/as-element empty-component)))
header (assoc :ListHeaderComponent (reagent/as-element header))
footer (assoc :ListFooterComponent (reagent/as-element footer))
on-drag-end-fn (assoc :onDragEnd (wrap-on-drag-end-fn on-drag-end-fn))
:always (merge (dissoc-custom-props props))))
(defn flat-list
[props]

View File

@ -1,11 +1,10 @@
(ns status-im.common.scalable-avatar.style)
(defn wrapper
[{:keys [scale margin-top margin border-color]}]
[{:transform [{:scale scale}]
:margin-top margin-top
:margin-left margin
:margin-bottom margin}
{:border-width 4
:border-color border-color
:border-radius 100}])
[border-color scale]
[{:transform-origin "bottom left"
:border-width 4
:border-color border-color
:border-radius 100
:transform [{:scale 1} {:translate-y 4}]}
{:transform [{:scale scale} {:translate-y 4}]}])

View File

@ -2,30 +2,14 @@
(:require [quo.core :as quo]
[react-native.reanimated :as reanimated]
[status-im.common.scalable-avatar.style :as style]))
(def scroll-animation-input-range [0 50])
(def header-extrapolation-option
{:extrapolateLeft "clamp"
:extrapolateRight "clamp"})
(defn f-avatar
(def scroll-range #js [0 48])
(def scale-range #js [1 0.4])
(defn view
[{:keys [scroll-y full-name online? profile-picture customization-color border-color]}]
(let [image-scale-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[1 0.4]
header-extrapolation-option)
image-top-margin-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[0 20]
header-extrapolation-option)
image-side-margin-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[-4 -20]
header-extrapolation-option)]
[reanimated/view
{:style (style/wrapper {:scale image-scale-animation
:margin-top image-top-margin-animation
:margin image-side-margin-animation
:border-color border-color})}
(let [image-scale (reanimated/interpolate scroll-y scroll-range scale-range :clamp)]
[reanimated/view {:style (style/wrapper border-color image-scale)}
[quo/user-avatar
{:full-name full-name
:online? online?
@ -34,7 +18,3 @@
:ring? true
:customization-color customization-color
:size :big}]]))
(defn view
[props]
[:f> f-avatar props])

View File

@ -10,14 +10,12 @@
[quo/documentation-drawers
{:title (i18n/label :t/forgot-your-password-info-title)
:shell? shell?}
[rn/view
{:style style/container}
[rn/view {:style style/container}
[quo/text {:size :paragraph-2} (i18n/label :t/forgot-your-password-info-description)]
[rn/view {:style style/step-container}
[quo/step {:in-blur-view? shell?} 1]
[rn/view
{:style style/step-content}
[rn/view {:style style/step-content}
[quo/text {:size :paragraph-2 :weight :semi-bold}
(i18n/label :t/forgot-your-password-info-remove-app)]
[quo/text {:size :paragraph-2} (i18n/label :t/forgot-your-password-info-remove-app-description)]]]
@ -33,10 +31,8 @@
[rn/view {:style style/step-container}
[quo/step {:in-blur-view? shell?} 3]
[rn/view
{:style style/step-content}
[rn/view
{:style style/step-title}
[rn/view {:style style/step-content}
[rn/view {:style style/step-title}
[quo/text {:size :paragraph-2} (str (i18n/label :t/sign-up) " ")]
[quo/text {:size :paragraph-2 :weight :semi-bold}
(i18n/label :t/forgot-your-password-info-signup-with-key)]]
@ -45,8 +41,7 @@
[rn/view {:style style/step-container}
[quo/step {:in-blur-view? shell?} 4]
[rn/view
{:style style/step-content}
[rn/view {:style style/step-content}
[quo/text {:size :paragraph-2 :weight :semi-bold}
(i18n/label :t/forgot-your-password-info-create-new-password)]
[quo/text {:size :paragraph-2}

View File

@ -276,8 +276,7 @@
(defn chat-user
[item]
[rn/view
{:style (merge style/container {:margin-horizontal 0})}
[rn/view {:style (assoc style/container :margin-horizontal 0)}
[chat-item item]])
(defn chat-list-item

View File

@ -62,8 +62,8 @@
:on-end-reached #(re-frame/dispatch [:chat/show-more-chats])
:keyboard-should-persist-taps :always
:data items
:render-fn (fn [item]
(chat-list-item/chat-list-item item theme))
:render-data {:theme theme}
:render-fn chat-list-item/chat-list-item
:scroll-event-throttle 8
:content-container-style {:padding-bottom
shell.constants/floating-shell-button-height
@ -94,8 +94,7 @@
{:selected-tab :tab/contacts
:tab->content (empty-state-content theme)}]
[rn/section-list
{:ref (when (not-empty items)
set-scroll-ref)
{:ref (when (seq items) set-scroll-ref)
:key-fn :public-key
:get-item-layout get-item-layout
:content-inset-adjustment-behavior :never
@ -108,8 +107,8 @@
:sticky-section-headers-enabled false
:render-section-header-fn contact-list/contacts-section-header
:render-section-footer-fn contact-list/contacts-section-footer
:render-fn (fn [data]
(contact-item-render data theme))
:render-data {:theme theme}
:render-fn contact-item-render
:scroll-event-throttle 8
:on-scroll #(common.banner/set-scroll-shared-value
{:scroll-input (oops/oget % "nativeEvent.contentOffset.y")

View File

@ -19,7 +19,7 @@
{:d "M20 20V0H0C11 0 20 9 20 20Z"
:fill background-color}]])
(defn f-view
(defn view
[{:keys [scroll-y customization-color theme]}]
(let [background-color (colors/resolve-color customization-color theme 40)
opacity-animation (reanimated/interpolate scroll-y
@ -30,7 +30,3 @@
[reanimated/view {:style (style/radius-container opacity-animation)}
[left-radius background-color]
[right-radius background-color]]]))
(defn view
[props]
[:f> f-view props])

View File

@ -1,11 +1,11 @@
(ns status-im.contexts.profile.settings.header.style)
(ns status-im.contexts.profile.settings.header.style
(:require [quo.foundations.colors :as colors]
[react-native.platform :as platform]))
(def avatar-row-wrapper
{:display :flex
:padding-left 20
{:padding-left 20
:padding-right 12
:margin-top -60
:margin-bottom -4
:margin-top -65
:align-items :flex-end
:justify-content :space-between
:flex-direction :row})
@ -21,3 +21,9 @@
{:opacity opacity-animation
:flex-direction :row
:justify-content :space-between})
(defn avatar-border-color
[theme]
(if platform/android?
colors/neutral-80-opa-80 ;; Fix is not needed because Android doesn't use blur
(colors/theme-colors colors/border-avatar-light colors/neutral-80-opa-80 theme)))

View File

@ -1,6 +1,5 @@
(ns status-im.contexts.profile.settings.header.view
(:require [quo.core :as quo]
[quo.foundations.colors :as colors]
[quo.theme]
[react-native.core :as rn]
[status-im.common.scalable-avatar.view :as avatar]
@ -11,49 +10,46 @@
[status-im.contexts.profile.utils :as profile.utils]
[utils.re-frame :as rf]))
(defn- on-state-dropdown-press
[]
(rf/dispatch [:show-bottom-sheet
{:shell? true
:theme :dark
:content visibility-sheet/view}]))
(defn view
[{:keys [scroll-y]}]
(let [theme (quo.theme/use-theme)
app-theme (rf/sub [:theme])
{:keys [public-key emoji-hash bio] :as profile} (rf/sub [:profile/profile-with-image])
online? (rf/sub [:visibility-status-updates/online?
public-key])
status (rf/sub
[:visibility-status-updates/visibility-status-update
public-key])
customization-color (rf/sub [:profile/customization-color])
full-name (profile.utils/displayed-name profile)
profile-picture (profile.utils/photo profile)
{:keys [status-title status-icon]} (header.utils/visibility-status-type-data status)
border-theme app-theme]
(let [app-theme (rf/sub [:theme])
{:keys [public-key emoji-hash bio]
:as profile} (rf/sub [:profile/profile-with-image])
online? (rf/sub [:visibility-status-updates/online? public-key])
status (rf/sub [:visibility-status-updates/visibility-status-update public-key])
customization-color (rf/sub [:profile/customization-color])
full-name (profile.utils/displayed-name profile)
profile-picture (profile.utils/photo profile)
{:keys [status-title
status-icon]} (header.utils/visibility-status-type-data status)]
[:<>
[header.shape/view
{:scroll-y scroll-y
:customization-color customization-color
:theme theme}]
:customization-color customization-color}]
[rn/view {:style style/avatar-row-wrapper}
[avatar/view
{:scroll-y scroll-y
:display-name full-name
:full-name full-name
:online? online?
:border-color (colors/theme-colors colors/border-avatar-light
colors/neutral-80-opa-80
border-theme)
:border-color (style/avatar-border-color app-theme)
:customization-color customization-color
:profile-picture profile-picture}]
[rn/view {:style {:margin-bottom 4}}
[quo/dropdown
{:background :blur
:size :size-32
:type :outline
:icon? true
:no-icon-color? true
:icon-name status-icon
:on-press #(rf/dispatch [:show-bottom-sheet
{:shell? true
:theme :dark
:content (fn [] [visibility-sheet/view])}])}
status-title]]]
[quo/dropdown
{:background :blur
:size :size-32
:type :outline
:icon? true
:no-icon-color? true
:icon-name status-icon
:on-press on-state-dropdown-press}
status-title]]
[quo/page-top
{:title-accessibility-label :username
:emoji-dash emoji-hash

View File

@ -1,7 +1,7 @@
(ns status-im.contexts.profile.settings.view
(:require [oops.core :as oops]
[quo.core :as quo]
[quo.theme :as quo.theme]
[quo.theme]
[react-native.core :as rn]
[react-native.reanimated :as reanimated]
[react-native.safe-area :as safe-area]