feat: drag to dismiss lightbox (#15349)

* feat: drag to dismiss lightbox
This commit is contained in:
Omar Basem 2023-03-15 16:37:01 +04:00 committed by GitHub
parent 8546727f84
commit f640eb8c8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 447 additions and 297 deletions

View File

@ -0,0 +1,10 @@
import { useDerivedValue } from 'react-native-reanimated';
export function infoLayout(input, isTop) {
return useDerivedValue(
function () {
'worklet'
return isTop ? input.value : -input.value
}
);
}

View File

@ -407,6 +407,7 @@ globalThis.__STATUS_MOBILE_JS_IDENTITY_PROXY__ = new Proxy({}, {get() { return (
"../src/js/worklets/bottom_sheet.js" #js {} "../src/js/worklets/bottom_sheet.js" #js {}
"../src/js/worklets/record_audio.js" #js {} "../src/js/worklets/record_audio.js" #js {}
"../src/js/worklets/scroll_view.js" #js {} "../src/js/worklets/scroll_view.js" #js {}
"../src/js/worklets/lightbox.js" #js {}
"./fleets.js" default-fleets "./fleets.js" default-fleets
"@walletconnect/client" wallet-connect-client "@walletconnect/client" wallet-connect-client
"../translations/ar.json" (js/JSON.parse (slurp "./translations/ar.json")) "../translations/ar.json" (js/JSON.parse (slurp "./translations/ar.json"))

View File

@ -5,7 +5,9 @@
RectButton RectButton
Swipeable Swipeable
TouchableWithoutFeedback TouchableWithoutFeedback
gestureHandlerRootHOC)] gestureHandlerRootHOC
FlatList)]
[react-native.flat-list :as rn-flat-list]
[reagent.core :as reagent])) [reagent.core :as reagent]))
(def gesture-detector (reagent/adapt-react-class GestureDetector)) (def gesture-detector (reagent/adapt-react-class GestureDetector))
@ -28,6 +30,8 @@
(defn on-finalize [gesture handler] (.onFinalize ^js gesture handler)) (defn on-finalize [gesture handler] (.onFinalize ^js gesture handler))
(defn max-pointers [gesture count] (.maxPointers ^js gesture count))
(defn number-of-taps [gesture count] (.numberOfTaps ^js gesture count)) (defn number-of-taps [gesture count] (.numberOfTaps ^js gesture count))
(defn enabled [gesture enabled?] (.enabled ^js gesture enabled?)) (defn enabled [gesture enabled?] (.enabled ^js gesture enabled?))
@ -63,3 +67,8 @@
(fn [& args] (fn [& args]
(reagent/as-element (apply render-right-actions args)))))] (reagent/as-element (apply render-right-actions args)))))]
children)) children))
(def gesture-flat-list (reagent/adapt-react-class FlatList))
(defn flat-list
[props]
[gesture-flat-list (rn-flat-list/base-list-props props)])

View File

@ -5,26 +5,21 @@
[react-native.reanimated :as reanimated] [react-native.reanimated :as reanimated]
[status-im2.contexts.chat.lightbox.style :as style] [status-im2.contexts.chat.lightbox.style :as style]
[utils.re-frame :as rf] [utils.re-frame :as rf]
[status-im2.contexts.chat.lightbox.animations :as anim])) [status-im2.contexts.chat.lightbox.animations :as anim]
[status-im2.contexts.chat.lightbox.constants :as c]))
(def small-image-size 40)
(def focused-image-size 56)
(def small-list-height 80)
(defn get-small-item-layout (defn get-small-item-layout
[_ index] [_ index]
#js #js
{:length small-image-size {:length c/small-image-size
:offset (* (+ small-image-size 8) index) :offset (* (+ c/small-image-size 8) index)
:index index}) :index index})
(defn small-image (defn small-image
[item index _ {:keys [scroll-index atoms]}] [item index _ {:keys [scroll-index atoms]}]
[:f> [:f>
(fn [] (fn []
(let [size (if (= @scroll-index index) focused-image-size small-image-size) (let [size (if (= @scroll-index index) c/focused-image-size c/small-image-size)
size-value (anim/use-val size) size-value (anim/use-val size)
{:keys [scroll-index-lock? small-list-ref {:keys [scroll-index-lock? small-list-ref
flat-list-ref]} atoms] flat-list-ref]} atoms]
@ -51,22 +46,22 @@
{:border-radius 10})}]]))]) {:border-radius 10})}]]))])
(defn bottom-view (defn bottom-view
[messages index scroll-index insets animations item-width atoms] [messages index scroll-index insets animations derived item-width atoms]
[:f> [:f>
(fn [] (fn []
(let [text (get-in (first messages) [:content :text]) (let [text (get-in (first messages) [:content :text])
padding-horizontal (- (/ item-width 2) (/ focused-image-size 2))] padding-horizontal (- (/ item-width 2) (/ c/focused-image-size 2))]
[reanimated/linear-gradient [reanimated/linear-gradient
{:colors [:black :transparent] {:colors [:black :transparent]
:start {:x 0 :y 1} :start {:x 0 :y 1}
:end {:x 0 :y 0} :end {:x 0 :y 0}
:style (style/gradient-container insets animations)} :style (style/gradient-container insets animations derived)}
(when (not= text "placeholder") (when (not= text "placeholder")
[rn/text {:style style/text-style} text]) [rn/text {:style style/text-style} text])
[rn/flat-list [rn/flat-list
{:ref #(reset! (:small-list-ref atoms) %) {:ref #(reset! (:small-list-ref atoms) %)
:key-fn :message-id :key-fn :message-id
:style {:height small-list-height} :style {:height c/small-list-height}
:data messages :data messages
:render-fn small-image :render-fn small-image
:render-data {:scroll-index scroll-index :render-data {:scroll-index scroll-index

View File

@ -0,0 +1,13 @@
(ns status-im2.contexts.chat.lightbox.constants)
(def ^:const small-image-size 40)
(def ^:const focused-extra-size 16)
(def ^:const focused-image-size (+ small-image-size focused-extra-size))
(def ^:const small-list-height 80)
(def ^:const small-list-padding-vertical 12)
(def ^:const top-view-height 56)

View File

@ -1,13 +1,23 @@
(ns status-im2.contexts.chat.lightbox.style (ns status-im2.contexts.chat.lightbox.style
(:require [quo2.foundations.colors :as colors] (:require [quo2.foundations.colors :as colors]
[react-native.platform :as platform] [react-native.platform :as platform]
[react-native.reanimated :as reanimated])) [react-native.reanimated :as reanimated]
[status-im2.contexts.chat.lightbox.constants :as c]))
;;;; VIEW
(defn image
[width height]
{:flex-direction :row
:width width
:height height
:align-items :center
:justify-content :center})
;;;; TOP-VIEW ;;;; TOP-VIEW
(defn top-view-container (defn top-view-container
[top-inset {:keys [opacity rotate top-view-y top-view-x top-view-width top-view-bg top-layout]} [top-inset window-width bg-color landscape?
window-width {:keys [opacity rotate top-view-y top-view-x top-view-width top-view-bg]}
bg-color] {:keys [top-layout]}]
(reanimated/apply-animations-to-style (reanimated/apply-animations-to-style
(if platform/ios? (if platform/ios?
{:transform [{:translateY top-layout} {:transform [{:translateY top-layout}
@ -21,9 +31,8 @@
:opacity opacity}) :opacity opacity})
{:position :absolute {:position :absolute
:padding-horizontal 20 :padding-horizontal 20
:top (if platform/ios? top-inset 0) :top (if (or platform/ios? (not landscape?)) top-inset 0)
;; height defined in top_view.cljs, but can't import due to circular dependency :height c/top-view-height
:height 56
:z-index 4 :z-index 4
:flex-direction :row :flex-direction :row
:justify-content :space-between :justify-content :space-between
@ -31,6 +40,14 @@
:background-color (when platform/android? bg-color) :background-color (when platform/android? bg-color)
:align-items :center})) :align-items :center}))
(defn top-gradient
[insets]
{:position :absolute
:height (+ c/top-view-height (:top insets) 0)
:top (- (:top insets))
:left 0
:right 0})
(def close-container (def close-container
{:width 32 {:width 32
:height 32 :height 32
@ -44,18 +61,20 @@
;;;; BOTTOM-VIEW ;;;; BOTTOM-VIEW
(defn gradient-container (defn gradient-container
[insets {:keys [opacity bottom-layout]}] [insets {:keys [opacity]} {:keys [bottom-layout]}]
(reanimated/apply-animations-to-style (reanimated/apply-animations-to-style
{:transform [{:translateY bottom-layout}] {:transform [{:translateY bottom-layout}]
:opacity opacity} :opacity opacity}
{:position :absolute {:position :absolute
:bottom 0 :bottom 0
:padding-bottom (:bottom insets) :padding-bottom (if platform/ios?
(:bottom insets)
(+ (:bottom insets) c/small-list-padding-vertical c/focused-extra-size))
:z-index 3})) :z-index 3}))
(defn content-container (defn content-container
[padding-horizontal] [padding-horizontal]
{:padding-vertical 12 {:padding-vertical c/small-list-padding-vertical
:padding-horizontal padding-horizontal :padding-horizontal padding-horizontal
:align-items :center :align-items :center
:justify-content :center}) :justify-content :center})

View File

@ -9,14 +9,13 @@
[status-im2.contexts.chat.lightbox.animations :as anim] [status-im2.contexts.chat.lightbox.animations :as anim]
[status-im2.contexts.chat.lightbox.style :as style] [status-im2.contexts.chat.lightbox.style :as style]
[utils.datetime :as datetime] [utils.datetime :as datetime]
[utils.re-frame :as rf])) [utils.re-frame :as rf]
[status-im2.contexts.chat.lightbox.constants :as c]))
(def ^:const top-view-height 56)
(defn animate-rotation (defn animate-rotation
[result screen-width screen-height insets-atom [result screen-width screen-height insets
{:keys [rotate top-view-y top-view-x top-view-width top-view-bg]}] {:keys [rotate top-view-y top-view-x top-view-width top-view-bg]}]
(let [top-x (+ (/ top-view-height 2) (:top insets-atom))] (let [top-x (+ (/ c/top-view-height 2) (:top insets))]
(cond (cond
(= result orientation/landscape-left) (= result orientation/landscape-left)
(do (do
@ -41,22 +40,30 @@
(anim/animate top-view-bg colors/neutral-100-opa-0))))) (anim/animate top-view-bg colors/neutral-100-opa-0)))))
(defn top-view (defn top-view
[{:keys [from timestamp]} insets index animations landscape? screen-width] [{:keys [from timestamp]} insets index animations derived landscape? screen-width]
[:f> [:f>
(fn [] (fn []
(let [display-name (first (rf/sub [:contacts/contact-two-names-by-identity from])) (let [display-name (first (rf/sub [:contacts/contact-two-names-by-identity
bg-color (if landscape? colors/neutral-100-opa-70 colors/neutral-100-opa-0)] from]))
bg-color (if landscape?
colors/neutral-100-opa-70
colors/neutral-100-opa-0)
{:keys [background-color opacity]} animations]
[reanimated/view [reanimated/view
{:style (style/top-view-container (:top insets) animations screen-width bg-color)} {:style
(style/top-view-container (:top insets) screen-width bg-color landscape? animations derived)}
[reanimated/linear-gradient
{:colors [(colors/alpha "#000000" 0.8) :transparent]
:start {:x 0 :y 0}
:end {:x 0 :y 1}
:style (style/top-gradient insets)}]
[rn/view [rn/view
{:style {:flex-direction :row {:style {:flex-direction :row
:align-items :center}} :align-items :center}}
[rn/touchable-opacity [rn/touchable-opacity
{:on-press (fn [] {:on-press (fn []
(when platform/ios? (anim/animate background-color :transparent)
(anim/animate (:background-color animations) (anim/animate opacity 0)
(reanimated/with-timing "rgba(0,0,0,0)")))
(anim/animate (:opacity animations) 0)
(rf/dispatch (if platform/ios? (rf/dispatch (if platform/ios?
[:chat.ui/exit-lightbox-signal @index] [:chat.ui/exit-lightbox-signal @index]
[:navigate-back]))) [:navigate-back])))

View File

@ -3,25 +3,34 @@
[clojure.string :as string] [clojure.string :as string]
[quo2.foundations.colors :as colors] [quo2.foundations.colors :as colors]
[react-native.core :as rn] [react-native.core :as rn]
[react-native.navigation :as navigation]
[react-native.orientation :as orientation] [react-native.orientation :as orientation]
[react-native.platform :as platform] [react-native.platform :as platform]
[react-native.reanimated :as reanimated] [react-native.reanimated :as reanimated]
[status-im2.contexts.chat.lightbox.animations :as anim] [status-im2.contexts.chat.lightbox.animations :as anim]
[status-im2.contexts.chat.lightbox.style :as style]
[utils.re-frame :as rf] [utils.re-frame :as rf]
[react-native.safe-area :as safe-area]
[reagent.core :as reagent] [reagent.core :as reagent]
[react-native.gesture :as gesture]
[status-im2.contexts.chat.lightbox.zoomable-image.view :as zoomable-image] [status-im2.contexts.chat.lightbox.zoomable-image.view :as zoomable-image]
[status-im2.contexts.chat.lightbox.top-view :as top-view] [status-im2.contexts.chat.lightbox.top-view :as top-view]
[status-im2.contexts.chat.lightbox.bottom-view :as bottom-view] [status-im2.contexts.chat.lightbox.bottom-view :as bottom-view]
[utils.worklets.lightbox :as worklet]
[oops.core :refer [oget]])) [oops.core :refer [oget]]))
(def seperator-width 16) (def ^:const seperator-width 16)
(def ^:const drag-threshold 100)
(defn toggle-opacity (defn toggle-opacity
[opacity-value border-value transparent? index {:keys [small-list-ref]}] [index {:keys [opacity-value border-value transparent? atoms]} portrait?]
(let [opacity (reanimated/get-shared-value opacity-value)] (let [{:keys [small-list-ref]} atoms
opacity (reanimated/get-shared-value opacity-value)]
(if (= opacity 1) (if (= opacity 1)
(do (do
(when platform/ios?
;; status-bar issue: https://github.com/status-im/status-mobile/issues/15343
(js/setTimeout #(navigation/merge-options "lightbox" {:statusBar {:visible false}}) 75))
(anim/animate opacity-value 0) (anim/animate opacity-value 0)
(js/setTimeout #(reset! transparent? (not @transparent?)) 400)) (js/setTimeout #(reset! transparent? (not @transparent?)) 400))
(do (do
@ -29,17 +38,19 @@
(js/setTimeout #(anim/animate opacity-value 1) 50) (js/setTimeout #(anim/animate opacity-value 1) 50)
(js/setTimeout #(when @small-list-ref (js/setTimeout #(when @small-list-ref
(.scrollToIndex ^js @small-list-ref #js {:animated false :index index})) (.scrollToIndex ^js @small-list-ref #js {:animated false :index index}))
100))) 100)
(when (and platform/ios? portrait?)
(js/setTimeout #(navigation/merge-options "lightbox" {:statusBar {:visible true}}) 150))))
(anim/animate border-value (if (= opacity 1) 0 12)))) (anim/animate border-value (if (= opacity 1) 0 12))))
(defn handle-orientation (defn handle-orientation
[result index window animations {:keys [flat-list-ref insets-atom]}] [result index window-width window-height animations insets {:keys [flat-list-ref]}]
(let [screen-width (if (or platform/ios? (= result orientation/portrait)) (let [screen-width (if (or platform/ios? (= result orientation/portrait))
(:width window) window-width
(:height window)) window-height)
screen-height (if (or platform/ios? (= result orientation/portrait)) screen-height (if (or platform/ios? (= result orientation/portrait))
(:height window) window-height
(:width window)) window-width)
landscape? (string/includes? result orientation/landscape) landscape? (string/includes? result orientation/landscape)
item-width (if (and landscape? platform/ios?) screen-height screen-width) item-width (if (and landscape? platform/ios?) screen-height screen-width)
timeout (if platform/ios? 50 100)] timeout (if platform/ios? 50 100)]
@ -56,7 +67,7 @@
#js {:animated false :offset (* (+ item-width seperator-width) @index)})) #js {:animated false :offset (* (+ item-width seperator-width) @index)}))
timeout) timeout)
(when platform/ios? (when platform/ios?
(top-view/animate-rotation result screen-width screen-height @insets-atom animations)))) (top-view/animate-rotation result screen-width screen-height insets animations))))
(defn get-item-layout (defn get-item-layout
[_ index item-width] [_ index item-width]
@ -73,77 +84,104 @@
(rf/dispatch [:chat.ui/update-shared-element-id (:message-id (oget changed :item))])))) (rf/dispatch [:chat.ui/update-shared-element-id (:message-id (oget changed :item))]))))
(defn image (defn image
[message index _ {:keys [opacity-value border-value transparent? width height atoms]}] [message index _ {:keys [screen-width screen-height] :as args}]
[:f> [rn/view
(fn [] {:style (style/image (+ screen-width seperator-width) screen-height)}
[rn/view [zoomable-image/zoomable-image message index args
{:style {:flex-direction :row #(toggle-opacity index args %)]
:width (+ width seperator-width) [rn/view {:style {:width seperator-width}}]])
:height height
:align-items :center
:justify-content :center}}
[zoomable-image/zoomable-image message index border-value
#(toggle-opacity opacity-value border-value transparent? index atoms)]
[rn/view {:style {:width seperator-width}}]])])
;; using `safe-area/consumer` in this component in iOS causes unnecessary re-renders and weird behaviour (defn drag-gesture
;; using `use-safe-area` on Android crashes the app with error `rendered fewer hooks than expected` [{:keys [pan-x pan-y background-color opacity layout]} x? set-full-height?]
(defn container-view (->
[children] (gesture/gesture-pan)
(if platform/ios? (gesture/enabled true)
[:f> children] (gesture/max-pointers 1)
[safe-area/consumer children])) (gesture/on-start #(reset! set-full-height? false))
(gesture/on-update (fn [e]
(let [translation (if x? (oget e "translationX") (oget e "translationY"))
progress (Math/abs (/ translation drag-threshold))]
(anim/set-val (if x? pan-x pan-y) translation)
(anim/set-val opacity (- 1 progress))
(anim/set-val layout (* progress -20)))))
(gesture/on-end (fn [e]
(if (> (Math/abs (if x? (oget e "translationX") (oget e "translationY")))
drag-threshold)
(do
(anim/animate background-color "rgba(0,0,0,0)")
(anim/animate opacity 0)
(rf/dispatch [:navigate-back]))
(do
#(reset! set-full-height? true)
(anim/animate (if x? pan-x pan-y) 0)
(anim/animate opacity 1)
(anim/animate layout 0)))))))
(defn lightbox (defn lightbox
[] []
[:f> [:f>
(fn [] (fn []
(let [{:keys [messages index]} (rf/sub [:get-screen-params]) ;; we get `insets` from `screen-params` because trying to consume it from
atoms {:flat-list-ref (atom nil) ;; lightbox screen causes lots of problems
:small-list-ref (atom nil) (let [{:keys [messages index insets]} (rf/sub [:get-screen-params])
:scroll-index-lock? (atom true) atoms {:flat-list-ref (atom nil)
:insets-atom (atom nil)} :small-list-ref (atom nil)
:scroll-index-lock? (atom true)}
;; The initial value of data is the image that was pressed (and not the whole album) in order ;; The initial value of data is the image that was pressed (and not the whole album) in order
;; for the transition animation to execute properly, otherwise it would animate towards ;; for the transition animation to execute properly, otherwise it would animate towards
;; outside the screen (even if we have `initialScrollIndex` set). ;; outside the screen (even if we have `initialScrollIndex` set).
data (reagent/atom [(nth messages index)]) data (reagent/atom [(nth messages index)])
scroll-index (reagent/atom index) scroll-index (reagent/atom index)
transparent? (reagent/atom false) transparent? (reagent/atom false)
window (rf/sub [:dimensions/window]) set-full-height? (reagent/atom false)
animations {:background-color (anim/use-val "rgba(0,0,0,0)") window (rf/sub [:dimensions/window])
:border (anim/use-val (if platform/ios? 0 12)) window-width (:width window)
:opacity (anim/use-val 0) window-height (:height window)
:rotate (anim/use-val "0deg") window-height (if platform/android?
:top-layout (anim/use-val -10) (+ window-height (:top insets))
:bottom-layout (anim/use-val 10) window-height)
:top-view-y (anim/use-val 0) animations {:background-color (anim/use-val "rgba(0,0,0,0)")
:top-view-x (anim/use-val 0) :border (anim/use-val (if platform/ios? 0 12))
:top-view-width (anim/use-val (:width window)) :opacity (anim/use-val 0)
:top-view-bg (anim/use-val colors/neutral-100-opa-0)} :rotate (anim/use-val "0deg")
:layout (anim/use-val -10)
callback (fn [e] :top-view-y (anim/use-val 0)
(on-viewable-items-changed e scroll-index atoms)) :top-view-x (anim/use-val 0)
insets-ios (when platform/ios? (safe-area/use-safe-area))] :top-view-width (anim/use-val window-width)
:top-view-bg (anim/use-val colors/neutral-100-opa-0)
:pan-y (anim/use-val 0)
:pan-x (anim/use-val 0)}
derived {:top-layout (worklet/info-layout (:layout animations)
true)
:bottom-layout (worklet/info-layout (:layout animations)
false)}
callback (fn [e]
(on-viewable-items-changed e scroll-index atoms))]
(anim/animate (:background-color animations) "rgba(0,0,0,1)") (anim/animate (:background-color animations) "rgba(0,0,0,1)")
(reset! data messages) (reset! data messages)
(orientation/use-device-orientation-change (orientation/use-device-orientation-change
(fn [result] (fn [result]
(if platform/ios? (if platform/ios?
(handle-orientation result scroll-index window animations atoms) (handle-orientation result scroll-index window-width window-height animations insets atoms)
;; `use-device-orientation-change` will always be called on Android, so need to check ;; `use-device-orientation-change` will always be called on Android, so need to check
(orientation/get-auto-rotate-state (orientation/get-auto-rotate-state
(fn [enabled?] (fn [enabled?]
;; RNN does not support landscape-right ;; RNN does not support landscape-right
(when (and enabled? (not= result orientation/landscape-right)) (when (and enabled? (not= result orientation/landscape-right))
(handle-orientation result scroll-index window animations atoms))))))) (handle-orientation result
scroll-index
window-width
window-height
animations
insets
atoms)))))))
(rn/use-effect (fn [] (rn/use-effect (fn []
(when @(:flat-list-ref atoms) (when @(:flat-list-ref atoms)
(.scrollToIndex ^js @(:flat-list-ref atoms) (.scrollToIndex ^js @(:flat-list-ref atoms)
#js {:animated false :index index})) #js {:animated false :index index}))
(js/setTimeout (fn [] (js/setTimeout (fn []
(anim/animate (:opacity animations) 1) (anim/animate (:opacity animations) 1)
(anim/animate (:top-layout animations) 0) (anim/animate (:layout animations) 0)
(anim/animate (:bottom-layout animations) 0)
(anim/animate (:border animations) 12)) (anim/animate (:border animations) 12))
(if platform/ios? 250 100)) (if platform/ios? 250 100))
(js/setTimeout #(reset! (:scroll-index-lock? atoms) false) 300) (js/setTimeout #(reset! (:scroll-index-lock? atoms) false) 300)
@ -151,53 +189,57 @@
(rf/dispatch [:chat.ui/zoom-out-signal nil]) (rf/dispatch [:chat.ui/zoom-out-signal nil])
(when platform/android? (when platform/android?
(rf/dispatch [:chat.ui/lightbox-scale 1]))))) (rf/dispatch [:chat.ui/lightbox-scale 1])))))
[container-view [:f>
(fn [insets-android] (fn []
(let [insets (if platform/ios? insets-ios insets-android) (let [curr-orientation (or (rf/sub [:lightbox/orientation]) orientation/portrait)
curr-orientation (or (rf/sub [:lightbox/orientation]) orientation/portrait)
landscape? (string/includes? curr-orientation orientation/landscape) landscape? (string/includes? curr-orientation orientation/landscape)
horizontal? (or platform/android? (not landscape?)) horizontal? (or platform/android? (not landscape?))
inverted? (and platform/ios? (= curr-orientation orientation/landscape-right)) inverted? (and platform/ios? (= curr-orientation orientation/landscape-right))
screen-width (if (or platform/ios? (= curr-orientation orientation/portrait)) screen-width (if (or platform/ios? (= curr-orientation orientation/portrait))
(:width window) window-width
(:height window)) window-height)
screen-height (if (or platform/ios? (= curr-orientation orientation/portrait)) screen-height (if (or platform/ios? (= curr-orientation orientation/portrait))
(:height window) window-height
(:width window)) window-width)
item-width (if (and landscape? platform/ios?) screen-height screen-width)] item-width (if (and landscape? platform/ios?) screen-height screen-width)]
(reset! (:insets-atom atoms) insets)
[reanimated/view [reanimated/view
{:style (if platform/ios? {:style (reanimated/apply-animations-to-style {:background-color (:background-color
(reanimated/apply-animations-to-style {:background-color (:background-color animations)}
animations)} {:height screen-height})}
{})
{:background-color :black})}
(when-not @transparent? (when-not @transparent?
[top-view/top-view (first messages) insets scroll-index animations landscape? [top-view/top-view (first messages) insets scroll-index animations derived landscape?
screen-width]) screen-width])
[rn/flat-list [gesture/gesture-detector
{:ref #(reset! (:flat-list-ref atoms) %) {:gesture (drag-gesture animations (and landscape? platform/ios?) set-full-height?)}
:key-fn :message-id [reanimated/view
:style {:width (+ screen-width seperator-width)} {:style (reanimated/apply-animations-to-style
:data @data {:transform [{:translateY (:pan-y animations)}
:render-fn image {:translateX (:pan-x animations)}]}
:render-data {:opacity-value (:opacity animations) {})}
:border-value (:border animations) [gesture/flat-list
:transparent? transparent? {:ref #(reset! (:flat-list-ref atoms) %)
:height screen-height :key-fn :message-id
:width screen-width :style {:width (+ screen-width seperator-width)}
:atoms atoms} :data @data
:horizontal horizontal? :render-fn image
:inverted inverted? :render-data {:opacity-value (:opacity animations)
:paging-enabled true :border-value (:border animations)
:get-item-layout (fn [_ index] (get-item-layout _ index item-width)) :transparent? transparent?
:viewability-config {:view-area-coverage-percent-threshold 50 :set-full-height? set-full-height?
:wait-for-interaction true} :screen-height screen-height
:shows-vertical-scroll-indicator false :screen-width screen-width
:shows-horizontal-scroll-indicator false :window-height window-height
:on-viewable-items-changed callback :window-width window-width
:content-container-style {:justify-content :center :atoms atoms}
:align-items :center}}] :horizontal horizontal?
:inverted inverted?
:paging-enabled true
:get-item-layout (fn [_ index] (get-item-layout _ index item-width))
:viewability-config {:view-area-coverage-percent-threshold 50
:wait-for-interaction true}
:shows-vertical-scroll-indicator false
:shows-horizontal-scroll-indicator false
:on-viewable-items-changed callback}]]]
(when (and (not @transparent?) (not landscape?)) (when (and (not @transparent?) (not landscape?))
[bottom-view/bottom-view messages index scroll-index insets animations [bottom-view/bottom-view messages index scroll-index insets animations derived
item-width atoms])]))]))]) item-width atoms])]))]))])

View File

@ -37,10 +37,10 @@
{:keys [x-threshold-scale y-threshold-scale]} {:keys [x-threshold-scale y-threshold-scale]}
{:keys [scale saved-scale] :as animations} {:keys [scale saved-scale] :as animations}
{:keys [pan-x-enabled? pan-y-enabled?] :as props}] {:keys [pan-x-enabled? pan-y-enabled?] :as props}]
(anim/animate scale value (if exit? 100 c/default-duration))
(anim/set-val saved-scale value)
(when (= value c/min-scale) (when (= value c/min-scale)
(reset-values exit? animations props)) (reset-values exit? animations props))
(anim/animate scale value (if exit? 100 c/default-duration))
(anim/set-val saved-scale value)
(reset! pan-x-enabled? (> value x-threshold-scale)) (reset! pan-x-enabled? (> value x-threshold-scale))
(reset! pan-y-enabled? (> value y-threshold-scale))) (reset! pan-y-enabled? (> value y-threshold-scale)))
@ -51,24 +51,37 @@
{:keys [landscape-scale-val x-threshold-scale y-threshold-scale]} {:keys [landscape-scale-val x-threshold-scale y-threshold-scale]}
{:keys [rotate rotate-scale scale] :as animations} {:keys [rotate rotate-scale scale] :as animations}
{:keys [pan-x-enabled? pan-y-enabled?]}] {:keys [pan-x-enabled? pan-y-enabled?]}]
(let [duration (when focused? c/default-duration)] (if focused?
(cond (cond
(= curr-orientation orientation/landscape-left) (= curr-orientation orientation/landscape-left)
(do (do
(anim/animate rotate "90deg" duration) (anim/animate rotate "90deg")
(anim/animate rotate-scale landscape-scale-val duration)) (anim/animate rotate-scale landscape-scale-val))
(= curr-orientation orientation/landscape-right) (= curr-orientation orientation/landscape-right)
(do (do
(anim/animate rotate "-90deg" duration) (anim/animate rotate "-90deg")
(anim/animate rotate-scale landscape-scale-val duration)) (anim/animate rotate-scale landscape-scale-val))
(= curr-orientation orientation/portrait) (= curr-orientation orientation/portrait)
(do (do
(anim/animate rotate c/init-rotation duration) (anim/animate rotate c/init-rotation)
(anim/animate rotate-scale c/min-scale duration))) (anim/animate rotate-scale c/min-scale)))
(center-x animations false) (cond
(center-y animations false) (= curr-orientation orientation/landscape-left)
(reset! pan-x-enabled? (> (anim/get-val scale) x-threshold-scale)) (do
(reset! pan-y-enabled? (> (anim/get-val scale) y-threshold-scale)))) (anim/set-val rotate "90deg")
(anim/set-val rotate-scale landscape-scale-val))
(= curr-orientation orientation/landscape-right)
(do
(anim/set-val rotate "-90deg")
(anim/set-val rotate-scale landscape-scale-val))
(= curr-orientation orientation/portrait)
(do
(anim/set-val rotate c/init-rotation)
(anim/set-val rotate-scale c/min-scale))))
(center-x animations false)
(center-y animations false)
(reset! pan-x-enabled? (> (anim/get-val scale) x-threshold-scale))
(reset! pan-y-enabled? (> (anim/get-val scale) y-threshold-scale)))
;; On ios, when attempting to navigate back while zoomed in, the shared-element transition animation ;; On ios, when attempting to navigate back while zoomed in, the shared-element transition animation
;; doesn't execute properly, so we need to zoom out first ;; doesn't execute properly, so we need to zoom out first
@ -94,14 +107,9 @@
"Calculates all required dimensions. Dimensions calculations are different on iOS and Android because landscape "Calculates all required dimensions. Dimensions calculations are different on iOS and Android because landscape
mode is implemented differently.On Android, we just need to resize the content, and the OS takes care of the mode is implemented differently.On Android, we just need to resize the content, and the OS takes care of the
animations. On iOS, we need to animate the content ourselves in code" animations. On iOS, we need to animate the content ourselves in code"
[pixels-width pixels-height curr-orientation] [pixels-width pixels-height curr-orientation
(let [window (rf/sub [:dimensions/window]) {:keys [window-width screen-width screen-height]}]
landscape? (string/includes? curr-orientation orientation/landscape) (let [landscape? (string/includes? curr-orientation orientation/landscape)
portrait? (= curr-orientation orientation/portrait)
window-width (:width window)
window-height (:height window)
screen-width (if (or platform/ios? portrait?) window-width window-height)
screen-height (if (or platform/ios? portrait?) window-height window-width)
portrait-image-width window-width portrait-image-width window-width
portrait-image-height (* pixels-height (/ window-width pixels-width)) portrait-image-height (* pixels-height (/ window-width pixels-width))
landscape-image-width (* pixels-width (/ window-width pixels-height)) landscape-image-width (* pixels-width (/ window-width pixels-height))

View File

@ -22,7 +22,9 @@
(defn double-tap-gesture (defn double-tap-gesture
[{:keys [width height screen-width screen-height y-threshold-scale x-threshold-scale]} [{:keys [width height screen-width screen-height y-threshold-scale x-threshold-scale]}
{:keys [scale pan-x pan-x-start pan-y pan-y-start]} {:keys [scale pan-x pan-x-start pan-y pan-y-start]}
rescale] rescale
transparent?
toggle-opacity]
(-> (->
(gesture/gesture-tap) (gesture/gesture-tap)
(gesture/number-of-taps 2) (gesture/number-of-taps 2)
@ -37,8 +39,13 @@
(when (> c/double-tap-scale y-threshold-scale) (when (> c/double-tap-scale y-threshold-scale)
(anim/animate pan-y translate-y) (anim/animate pan-y translate-y)
(anim/set-val pan-y-start translate-y)) (anim/set-val pan-y-start translate-y))
(rescale c/double-tap-scale)) (rescale c/double-tap-scale)
(rescale c/min-scale)))))) (when (not @transparent?)
(toggle-opacity)))
(do
(rescale c/min-scale)
(when @transparent?
(toggle-opacity))))))))
;; not using on-finalize because on-finalize gets called always regardless the gesture executed or not ;; not using on-finalize because on-finalize gets called always regardless the gesture executed or not
(defn finalize-pinch (defn finalize-pinch
@ -74,7 +81,9 @@
{:keys [saved-scale scale pinch-x pinch-y pinch-x-start pinch-y-start pinch-x-max pinch-y-max] {:keys [saved-scale scale pinch-x pinch-y pinch-x-start pinch-y-start pinch-x-max pinch-y-max]
:as animations} :as animations}
{:keys [focal-x focal-y] :as props} {:keys [focal-x focal-y] :as props}
rescale] rescale
transparent?
toggle-opacity]
(-> (->
(gesture/gesture-pinch) (gesture/gesture-pinch)
(gesture/on-begin (fn [e] (gesture/on-begin (fn [e]
@ -82,6 +91,8 @@
(reset! focal-x (oget e "focalX")) (reset! focal-x (oget e "focalX"))
(reset! focal-y (utils/get-focal (oget e "focalY") height screen-height))))) (reset! focal-y (utils/get-focal (oget e "focalY") height screen-height)))))
(gesture/on-start (fn [e] (gesture/on-start (fn [e]
(when (and (= (anim/get-val saved-scale) c/min-scale) (not @transparent?))
(toggle-opacity))
(when platform/android? (when platform/android?
(reset! focal-x (utils/get-focal (oget e "focalX") width screen-width)) (reset! focal-x (utils/get-focal (oget e "focalX") width screen-width))
(reset! focal-y (utils/get-focal (oget e "focalY") height screen-height))))) (reset! focal-y (utils/get-focal (oget e "focalY") height screen-height)))))
@ -101,7 +112,10 @@
(fn [] (fn []
(cond (cond
(< (anim/get-val scale) c/min-scale) (< (anim/get-val scale) c/min-scale)
(rescale c/min-scale) (do
(when @transparent?
(toggle-opacity))
(rescale c/min-scale))
(> (anim/get-val scale) c/max-scale) (> (anim/get-val scale) c/max-scale)
(do (do
(anim/animate pinch-x (anim/get-val pinch-x-max)) (anim/animate pinch-x (anim/get-val pinch-x-max))
@ -191,64 +205,74 @@
(anim/animate-decay pan-y-start velocity [lower-bound upper-bound])))))))) (anim/animate-decay pan-y-start velocity [lower-bound upper-bound]))))))))
(defn zoomable-image (defn zoomable-image
[{:keys [image-width image-height content message-id]} index border-radius on-tap] [{:keys [image-width image-height content message-id]} index args on-tap]
(let [set-full-height? (reagent/atom false)] [:f>
[:f> (fn []
(fn [] (let [{:keys [transparent? set-full-height?]} args
(let [shared-element-id (rf/sub [:shared-element-id]) shared-element-id (rf/sub [:shared-element-id])
exit-lightbox-signal (rf/sub [:lightbox/exit-signal]) exit-lightbox-signal (rf/sub [:lightbox/exit-signal])
zoom-out-signal (rf/sub [:lightbox/zoom-out-signal]) zoom-out-signal (rf/sub [:lightbox/zoom-out-signal])
focused? (= shared-element-id message-id) focused? (= shared-element-id message-id)
curr-orientation (or (rf/sub [:lightbox/orientation]) orientation/portrait) curr-orientation (or (rf/sub [:lightbox/orientation])
dimensions (utils/get-dimensions image-width image-height curr-orientation) orientation/portrait)
animations {:scale (anim/use-val c/min-scale) portrait? (= curr-orientation orientation/portrait)
:saved-scale (anim/use-val c/min-scale) dimensions (utils/get-dimensions image-width
:pan-x-start (anim/use-val c/init-offset) image-height
:pan-x (anim/use-val c/init-offset) curr-orientation
:pan-y-start (anim/use-val c/init-offset) args)
:pan-y (anim/use-val c/init-offset) animations {:scale (anim/use-val c/min-scale)
:pinch-x-start (anim/use-val c/init-offset) :saved-scale (anim/use-val c/min-scale)
:pinch-x (anim/use-val c/init-offset) :pan-x-start (anim/use-val c/init-offset)
:pinch-y-start (anim/use-val c/init-offset) :pan-x (anim/use-val c/init-offset)
:pinch-y (anim/use-val c/init-offset) :pan-y-start (anim/use-val c/init-offset)
:pinch-x-max (anim/use-val js/Infinity) :pan-y (anim/use-val c/init-offset)
:pinch-y-max (anim/use-val js/Infinity) :pinch-x-start (anim/use-val c/init-offset)
:rotate (anim/use-val c/init-rotation) :pinch-x (anim/use-val c/init-offset)
:rotate-scale (anim/use-val c/min-scale)} :pinch-y-start (anim/use-val c/init-offset)
props {:pan-x-enabled? (reagent/atom false) :pinch-y (anim/use-val c/init-offset)
:pan-y-enabled? (reagent/atom false) :pinch-x-max (anim/use-val js/Infinity)
:focal-x (reagent/atom nil) :pinch-y-max (anim/use-val js/Infinity)
:focal-y (reagent/atom nil)} :rotate (anim/use-val c/init-rotation)
rescale (fn [value exit?] :rotate-scale (anim/use-val c/min-scale)}
(utils/rescale-image value exit? dimensions animations props))] props {:pan-x-enabled? (reagent/atom false)
(rn/use-effect-once (fn [] :pan-y-enabled? (reagent/atom false)
(js/setTimeout #(reset! set-full-height? true) 500) :focal-x (reagent/atom nil)
js/undefined)) :focal-y (reagent/atom nil)}
(when platform/ios? rescale (fn [value exit?]
(utils/handle-orientation-change curr-orientation focused? dimensions animations props) (utils/rescale-image value
(utils/handle-exit-lightbox-signal exit-lightbox-signal exit?
index dimensions
(anim/get-val (:scale animations)) animations
rescale props))]
set-full-height?)) (rn/use-effect (fn []
(utils/handle-zoom-out-signal zoom-out-signal index (anim/get-val (:scale animations)) rescale) (js/setTimeout #(reset! set-full-height? true) 500)))
[:f> (when platform/ios?
(fn [] (utils/handle-orientation-change curr-orientation focused? dimensions animations props)
(let [tap (tap-gesture on-tap) (utils/handle-exit-lightbox-signal exit-lightbox-signal
double-tap (double-tap-gesture dimensions animations rescale) index
pinch (pinch-gesture dimensions animations props rescale) (anim/get-val (:scale animations))
pan-x (pan-x-gesture dimensions animations props rescale) rescale
pan-y (pan-y-gesture dimensions animations props rescale) set-full-height?))
composed-gestures (gesture/exclusive (utils/handle-zoom-out-signal zoom-out-signal index (anim/get-val (:scale animations)) rescale)
(gesture/simultaneous pinch pan-x pan-y) [:f>
(gesture/exclusive double-tap tap))] (fn []
[gesture/gesture-detector {:gesture composed-gestures} (let [tap (tap-gesture #(on-tap portrait?))
[reanimated/view double-tap
{:style (style/container dimensions (double-tap-gesture dimensions animations rescale transparent? #(on-tap portrait?))
animations pinch
@set-full-height? (pinch-gesture dimensions animations props rescale transparent? #(on-tap portrait?))
(= curr-orientation orientation/portrait))} pan-x (pan-x-gesture dimensions animations props rescale)
[reanimated/fast-image pan-y (pan-y-gesture dimensions animations props rescale)
{:source {:uri (:image content)} composed-gestures (gesture/exclusive
:native-ID (when focused? :shared-element) (gesture/simultaneous pinch pan-x pan-y)
:style (style/image dimensions animations border-radius)}]]]))]))])) (gesture/exclusive double-tap tap))]
[gesture/gesture-detector {:gesture composed-gestures}
[reanimated/view
{:style (style/container dimensions
animations
@set-full-height?
(= curr-orientation orientation/portrait))}
[reanimated/fast-image
{:source {:uri (:image content)}
:native-ID (when focused? :shared-element)
:style (style/image dimensions animations (:border-value args))}]]]))]))])

View File

@ -3,6 +3,7 @@
[quo2.foundations.colors :as colors] [quo2.foundations.colors :as colors]
[react-native.core :as rn] [react-native.core :as rn]
[react-native.fast-image :as fast-image] [react-native.fast-image :as fast-image]
[react-native.safe-area :as safe-area]
[status-im2.contexts.chat.messages.content.album.style :as style] [status-im2.contexts.chat.messages.content.album.style :as style]
[status-im2.constants :as constants] [status-im2.constants :as constants]
[status-im2.contexts.chat.messages.content.image.view :as image] [status-im2.contexts.chat.messages.content.image.view :as image]
@ -19,57 +20,63 @@
(defn album-message (defn album-message
[{:keys [albumize?] :as message} context on-long-press] [{:keys [albumize?] :as message} context on-long-press]
(let [shared-element-id (rf/sub [:shared-element-id]) [:f>
first-image (first (:album message)) (fn []
album-style (if (> (:image-width first-image) (:image-height first-image)) (let [insets (safe-area/use-safe-area)
:landscape shared-element-id (rf/sub [:shared-element-id])
:portrait) first-image (first (:album message))
images-count (count (:album message)) album-style (if (> (:image-width first-image) (:image-height first-image))
;; album images are always square, except when we have 3 images, then they must be rectangular :landscape
;; (portrait or landscape) :portrait)
portrait? (and (= images-count rectangular-style-count) (= album-style :portrait)) images-count (count (:album message))
text (:text (:content first-image))] ;; album images are always square, except when we have 3 images, then they must be rectangular
(if (and albumize? (> images-count 1)) ;; (portrait or landscape)
[:<> portrait? (and (= images-count rectangular-style-count) (= album-style :portrait))
(when (not= text "placeholder") text (:text (:content first-image))]
[rn/view {:style {:margin-bottom 10}} [text/text-content first-image context]]) (if (and albumize? (> images-count 1))
[rn/view [:<>
{:style (style/album-container portrait?)} (when (not= text "placeholder")
(map-indexed [rn/view {:style {:margin-bottom 10}} [text/text-content first-image context]])
(fn [index item] [rn/view
(let [images-size-key (if (< images-count constants/max-album-photos) images-count :default) {:style (style/album-container portrait?)}
size (get-in constants/album-image-sizes [images-size-key index]) (map-indexed
dimensions (if (not= images-count rectangular-style-count) (fn [index item]
{:width size :height size} (let [images-size-key (if (< images-count constants/max-album-photos)
(find-size size album-style))] images-count
[rn/touchable-opacity :default)
{:key (:message-id item) size (get-in constants/album-image-sizes [images-size-key index])
:active-opacity 1 dimensions (if (not= images-count rectangular-style-count)
:on-long-press #(on-long-press message context) {:width size :height size}
:on-press (fn [] (find-size size album-style))]
(rf/dispatch [:chat.ui/update-shared-element-id (:message-id item)]) [rn/touchable-opacity
(js/setTimeout #(rf/dispatch [:navigate-to :lightbox {:key (:message-id item)
{:messages (:album message) :active-opacity 1
:index index}]) :on-long-press #(on-long-press message context)
100))} :on-press (fn []
[fast-image/fast-image (rf/dispatch [:chat.ui/update-shared-element-id (:message-id item)])
{:style (style/image dimensions index portrait? images-count) (js/setTimeout #(rf/dispatch [:navigate-to :lightbox
:source {:uri (:image (:content item))} {:messages (:album message)
:native-ID (when (and (= shared-element-id (:message-id item)) :index index
(< index constants/max-album-photos)) :insets insets}])
:shared-element)}] 100))}
(when (and (> images-count constants/max-album-photos) [fast-image/fast-image
(= index (- constants/max-album-photos 1))) {:style (style/image dimensions index portrait? images-count)
[rn/view :source {:uri (:image (:content item))}
{:style style/overlay} :native-ID (when (and (= shared-element-id (:message-id item))
[quo/text (< index constants/max-album-photos))
{:weight :bold :shared-element)}]
:size :heading-2 (when (and (> images-count constants/max-album-photos)
:style {:color colors/white}} (= index (- constants/max-album-photos 1)))
(str "+" (- images-count (dec constants/max-album-photos)))]])])) [rn/view
(:album message))]] {:style style/overlay}
[:<> [quo/text
(map-indexed {:weight :bold
(fn [index item] :size :heading-2
[image/image-message index item context #(on-long-press message context)]) :style {:color colors/white}}
(:album message))]))) (str "+" (- images-count (dec constants/max-album-photos)))]])]))
(:album message))]]
[:<>
(map-indexed
(fn [index item]
[image/image-message index item context #(on-long-press message context)])
(:album message))])))])

View File

@ -2,6 +2,7 @@
(:require (:require
[react-native.core :as rn] [react-native.core :as rn]
[react-native.fast-image :as fast-image] [react-native.fast-image :as fast-image]
[react-native.safe-area :as safe-area]
[status-im2.constants :as constants] [status-im2.constants :as constants]
[utils.re-frame :as rf] [utils.re-frame :as rf]
[status-im2.contexts.chat.messages.content.text.view :as text])) [status-im2.contexts.chat.messages.content.text.view :as text]))
@ -14,23 +15,28 @@
(defn image-message (defn image-message
[index {:keys [content image-width image-height message-id] :as message} context on-long-press] [index {:keys [content image-width image-height message-id] :as message} context on-long-press]
(let [dimensions (calculate-dimensions (or image-width 1000) (or image-height 1000)) [:f>
text (:text content)] (fn []
(fn [] (let [insets (safe-area/use-safe-area)
(let [shared-element-id (rf/sub [:shared-element-id])] dimensions (calculate-dimensions (or image-width 1000) (or image-height 1000))
[rn/touchable-opacity text (:text content)]
{:active-opacity 1 (fn []
:key message-id (let [shared-element-id (rf/sub [:shared-element-id])]
:style {:margin-top (when (> index 0) 10)} [rn/touchable-opacity
:on-long-press on-long-press {:active-opacity 1
:on-press (fn [] :key message-id
(rf/dispatch [:chat.ui/update-shared-element-id message-id]) :style {:margin-top (when (pos? index) 10)}
(js/setTimeout #(rf/dispatch [:navigate-to :lightbox :on-long-press on-long-press
{:messages [message] :index 0}]) :on-press (fn []
100))} (rf/dispatch [:chat.ui/update-shared-element-id message-id])
(when (and (not= text "placeholder") (= index 0)) (js/setTimeout #(rf/dispatch [:navigate-to :lightbox
[rn/view {:style {:margin-bottom 10}} [text/text-content message context]]) {:messages (:album message)
[fast-image/fast-image :index index
{:source {:uri (:image content)} :insets insets}])
:style (merge dimensions {:border-radius 12}) 100))}
:native-ID (when (= shared-element-id message-id) :shared-element)}]])))) (when (and (not= text "placeholder") (= index 0))
[rn/view {:style {:margin-bottom 10}} [text/text-content message context]])
[fast-image/fast-image
{:source {:uri (:image content)}
:style (merge dimensions {:border-radius 12})
:native-ID (when (= shared-element-id message-id) :shared-element)}]]))))])

View File

@ -61,9 +61,11 @@
{:name :lightbox {:name :lightbox
:insets {:top false :bottom false} :insets {:top false :bottom false}
:options {:topBar {:visible false} :options {:topBar {:visible false}
:statusBar {:backgroundColor colors/black :statusBar {:backgroundColor :transparent
:style :light :style :light
:animate true} :animate true
:drawBehind true
:translucent true}
:navigationBar {:backgroundColor colors/black} :navigationBar {:backgroundColor colors/black}
:layout {:componentBackgroundColor :transparent :layout {:componentBackgroundColor :transparent
:backgroundColor :transparent} :backgroundColor :transparent}

View File

@ -0,0 +1,7 @@
(ns utils.worklets.lightbox)
(def ^:private layout-worklets (js/require "../src/js/worklets/lightbox.js"))
(defn info-layout
[input top?]
(.infoLayout ^js layout-worklets input top?))