@tamagui/animate-presence 1.0.1-beta.59 → 1.0.1-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/animate-presence",
3
- "version": "1.0.1-beta.59",
3
+ "version": "1.0.1-beta.60",
4
4
  "source": "src/index.ts",
5
5
  "license": "MIT",
6
6
  "types": "./types/index.d.ts",
@@ -8,14 +8,15 @@
8
8
  "module": "dist/esm",
9
9
  "module:jsx": "dist/jsx",
10
10
  "files": [
11
+ "src",
11
12
  "types",
12
13
  "dist"
13
14
  ],
14
15
  "dependencies": {
15
- "@tamagui/core": "^1.0.1-beta.59"
16
+ "@tamagui/core": "^1.0.1-beta.60"
16
17
  },
17
18
  "devDependencies": {
18
- "@tamagui/build": "^1.0.1-beta.59"
19
+ "@tamagui/build": "^1.0.1-beta.60"
19
20
  },
20
21
  "scripts": {
21
22
  "build": "tamagui-build",
@@ -0,0 +1,305 @@
1
+ import { useForceUpdate, useIsomorphicLayoutEffect, useUnmountEffect } from '@tamagui/core'
2
+ import React, {
3
+ Children,
4
+ ReactElement,
5
+ ReactNode,
6
+ cloneElement,
7
+ isValidElement,
8
+ useContext,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+
13
+ import { LayoutGroupContext } from './LayoutGroupContext'
14
+ import { PresenceChild } from './PresenceChild'
15
+
16
+ React['keep']
17
+
18
+ export interface AnimatePresenceProps {
19
+ /**
20
+ * By passing `initial={false}`, `AnimatePresence` will disable any initial animations on children
21
+ * that are present when the component is first rendered.
22
+ */
23
+ initial?: boolean
24
+
25
+ /**
26
+ * When a component is removed, there's no longer a chance to update its props. So if a component's `exit`
27
+ * prop is defined as a dynamic variant and you want to pass a new `custom` prop, you can do so via `AnimatePresence`.
28
+ * This will ensure all leaving components animate using the latest data.
29
+ */
30
+ exitVariant?: string | null
31
+
32
+ enterVariant?: string | null
33
+
34
+ /**
35
+ * Fires when all exiting nodes have completed animating out.
36
+ */
37
+ onExitComplete?: () => void
38
+
39
+ /**
40
+ * If set to `true`, `AnimatePresence` will only render one component at a time. The exiting component
41
+ * will finish its exit animation before the entering component is rendered.
42
+ */
43
+ exitBeforeEnter?: boolean
44
+
45
+ /**
46
+ * Used in Framer to flag that sibling children *shouldn't* re-render as a result of a
47
+ * child being removed.
48
+ */
49
+ presenceAffectsLayout?: boolean
50
+ }
51
+
52
+ type ComponentKey = string | number
53
+
54
+ const getChildKey = (child: ReactElement<any>): ComponentKey => child.key || ''
55
+
56
+ const isDev = process.env.NODE_ENV !== 'production'
57
+
58
+ function updateChildLookup(
59
+ children: ReactElement<any>[],
60
+ allChildren: Map<ComponentKey, ReactElement<any>>
61
+ ) {
62
+ const seenChildren = isDev ? new Set<ComponentKey>() : null
63
+
64
+ children.forEach((child) => {
65
+ const key = getChildKey(child)
66
+
67
+ if (isDev && seenChildren && seenChildren.has(key)) {
68
+ console.warn(`Children of AnimatePresence require unique keys. "${key}" is a duplicate.`)
69
+
70
+ seenChildren.add(key)
71
+ }
72
+
73
+ allChildren.set(key, child)
74
+ })
75
+ }
76
+
77
+ function onlyElements(children: ReactNode): ReactElement<any>[] {
78
+ const filtered: ReactElement<any>[] = []
79
+
80
+ // We use forEach here instead of map as map mutates the component key by preprending `.$`
81
+ Children.forEach(children, (child, index) => {
82
+ if (isValidElement(child)) {
83
+ if (!child.key) {
84
+ if (process.env.NODE_ENV === 'development') {
85
+ console.warn(`No key given to AnimatePresence child, assigning index as key`)
86
+ }
87
+ filtered.push(
88
+ React.cloneElement(child, {
89
+ key: index,
90
+ })
91
+ )
92
+ } else {
93
+ filtered.push(child)
94
+ }
95
+ }
96
+ })
97
+
98
+ return filtered
99
+ }
100
+
101
+ /**
102
+ * `AnimatePresence` enables the animation of components that have been removed from the tree.
103
+ *
104
+ * When adding/removing more than a single child, every child **must** be given a unique `key` prop.
105
+ *
106
+ * Any `motion` components that have an `exit` property defined will animate out when removed from
107
+ * the tree.
108
+ *
109
+ * ```tsx
110
+ * import { motion, AnimatePresence } from 'framer-motion'
111
+ *
112
+ * export const Items = ({ items }) => (
113
+ * <AnimatePresence>
114
+ * {items.map(item => (
115
+ * <motion.div
116
+ * key={item.id}
117
+ * initial={{ opacity: 0 }}
118
+ * animate={{ opacity: 1 }}
119
+ * exit={{ opacity: 0 }}
120
+ * />
121
+ * ))}
122
+ * </AnimatePresence>
123
+ * )
124
+ * ```
125
+ *
126
+ * You can sequence exit animations throughout a tree using variants.
127
+ *
128
+ * If a child contains multiple `motion` components with `exit` props, it will only unmount the child
129
+ * once all `motion` components have finished animating out. Likewise, any components using
130
+ * `useEntering` all need to call `safeToRemove`.
131
+ *
132
+ * @public
133
+ */
134
+ export const AnimatePresence: React.FunctionComponent<
135
+ React.PropsWithChildren<AnimatePresenceProps>
136
+ > = ({
137
+ children,
138
+ enterVariant,
139
+ exitVariant,
140
+ initial = true,
141
+ onExitComplete,
142
+ exitBeforeEnter,
143
+ presenceAffectsLayout = true,
144
+ }) => {
145
+ // We want to force a re-render once all exiting animations have finished. We
146
+ // either use a local forceRender function, or one from a parent context if it exists.
147
+ let forceRender = useForceUpdate()
148
+ const forceRenderLayoutGroup = useContext(LayoutGroupContext).forceRender
149
+ if (forceRenderLayoutGroup) forceRender = forceRenderLayoutGroup
150
+
151
+ const [isMounted, setIsMounted] = useState(false)
152
+
153
+ useIsomorphicLayoutEffect(() => {
154
+ setIsMounted(true)
155
+ }, [])
156
+
157
+ // Filter out any children that aren't ReactElements. We can only track ReactElements with a props.key
158
+ const filteredChildren = onlyElements(children)
159
+ let childrenToRender = filteredChildren
160
+
161
+ const exiting = new Set<ComponentKey>()
162
+
163
+ // Keep a living record of the children we're actually rendering so we
164
+ // can diff to figure out which are entering and exiting
165
+ const presentChildren = useRef(childrenToRender)
166
+
167
+ // A lookup table to quickly reference components by key
168
+ const allChildren = useRef(new Map<ComponentKey, ReactElement<any>>()).current
169
+
170
+ // If this is the initial component render, just deal with logic surrounding whether
171
+ // we play onMount animations or not.
172
+ const isInitialRender = useRef(true)
173
+
174
+ useIsomorphicLayoutEffect(() => {
175
+ isInitialRender.current = false
176
+ updateChildLookup(filteredChildren, allChildren)
177
+ presentChildren.current = childrenToRender
178
+ })
179
+
180
+ useUnmountEffect(() => {
181
+ isInitialRender.current = true
182
+ allChildren.clear()
183
+ exiting.clear()
184
+ })
185
+
186
+ if (isInitialRender.current) {
187
+ return (
188
+ <>
189
+ {childrenToRender.map((child) => (
190
+ <PresenceChild
191
+ key={getChildKey(child)}
192
+ isEntering
193
+ exitVariant={exitVariant}
194
+ enterVariant={enterVariant}
195
+ initial={initial ? undefined : false}
196
+ presenceAffectsLayout={presenceAffectsLayout}
197
+ >
198
+ {child}
199
+ </PresenceChild>
200
+ ))}
201
+ </>
202
+ )
203
+ }
204
+
205
+ // If this is a subsequent render, deal with entering and exiting children
206
+ childrenToRender = [...childrenToRender]
207
+
208
+ // Diff the keys of the currently-present and target children to update our
209
+ // exiting list.
210
+ const presentKeys = presentChildren.current.map(getChildKey)
211
+ const targetKeys = filteredChildren.map(getChildKey)
212
+
213
+ // Diff the present children with our target children and mark those that are exiting
214
+ const numPresent = presentKeys.length
215
+ for (let i = 0; i < numPresent; i++) {
216
+ const key = presentKeys[i]
217
+
218
+ if (targetKeys.indexOf(key) === -1) {
219
+ exiting.add(key)
220
+ }
221
+ }
222
+
223
+ // If we currently have exiting children, and we're deferring rendering incoming children
224
+ // until after all current children have exiting, empty the childrenToRender array
225
+ if (exitBeforeEnter && exiting.size) {
226
+ childrenToRender = []
227
+ }
228
+
229
+ // Loop through all currently exiting components and clone them to overwrite `animate`
230
+ // with any `exit` prop they might have defined.
231
+ exiting.forEach((key) => {
232
+ // If this component is actually entering again, early return
233
+ if (targetKeys.indexOf(key) !== -1) return
234
+
235
+ const child = allChildren.get(key)
236
+ if (!child) return
237
+
238
+ const insertionIndex = presentKeys.indexOf(key)
239
+
240
+ childrenToRender.splice(
241
+ insertionIndex,
242
+ 0,
243
+ <PresenceChild
244
+ key={getChildKey(child)}
245
+ isEntering={false}
246
+ onExitComplete={() => {
247
+ allChildren.delete(key)
248
+ exiting.delete(key)
249
+
250
+ // Remove this child from the present children
251
+ const removeIndex = presentChildren.current.findIndex(
252
+ (presentChild) => presentChild.key === key
253
+ )
254
+ presentChildren.current.splice(removeIndex, 1)
255
+
256
+ // Defer re-rendering until all exiting children have indeed left
257
+ if (!exiting.size) {
258
+ presentChildren.current = filteredChildren
259
+
260
+ if (isMounted === false) return
261
+
262
+ forceRender()
263
+ onExitComplete?.()
264
+ }
265
+ }}
266
+ exitVariant={exitVariant}
267
+ enterVariant={enterVariant}
268
+ presenceAffectsLayout={presenceAffectsLayout}
269
+ >
270
+ {child}
271
+ </PresenceChild>
272
+ )
273
+ })
274
+
275
+ // Add `MotionContext` even to children that don't need it to ensure we're rendering
276
+ // the same tree between renders
277
+ childrenToRender = childrenToRender.map((child) => {
278
+ const key = child.key as string | number
279
+ return exiting.has(key) ? (
280
+ child
281
+ ) : (
282
+ <PresenceChild
283
+ key={getChildKey(child)}
284
+ isEntering={undefined}
285
+ exitVariant={exitVariant}
286
+ enterVariant={enterVariant}
287
+ presenceAffectsLayout={presenceAffectsLayout}
288
+ >
289
+ {child}
290
+ </PresenceChild>
291
+ )
292
+ })
293
+
294
+ if (process.env.NODE_ENV !== 'production' && exitBeforeEnter && childrenToRender.length > 1) {
295
+ console.warn(
296
+ `You're attempting to animate multiple children within AnimatePresence, but its exitBeforeEnter prop is set to true. This will lead to odd visual behaviour.`
297
+ )
298
+ }
299
+
300
+ return (
301
+ <>{exiting.size ? childrenToRender : childrenToRender.map((child) => cloneElement(child))}</>
302
+ )
303
+ }
304
+
305
+ AnimatePresence.displayName = 'AnimatePresence'
@@ -0,0 +1,15 @@
1
+ import { createContext } from 'react'
2
+
3
+ import { VariantLabels } from './types'
4
+
5
+ export interface AnimatePresenceContextProps {
6
+ id: string
7
+ isEntering: boolean | undefined
8
+ register: (id: string) => () => void
9
+ onExitComplete?: (id: string) => void
10
+ initial?: false | VariantLabels
11
+ exitVariant?: string | null
12
+ enterVariant?: string | null
13
+ }
14
+
15
+ export const AnimatePresenceContext = createContext<AnimatePresenceContextProps | null>(null)
@@ -0,0 +1,8 @@
1
+ import { createContext } from 'react'
2
+
3
+ export interface LayoutGroupContextProps {
4
+ id?: string
5
+ forceRender?: VoidFunction
6
+ }
7
+
8
+ export const LayoutGroupContext = createContext<LayoutGroupContextProps>({})
@@ -0,0 +1,82 @@
1
+ import { useConstant, useId } from '@tamagui/core'
2
+ import * as React from 'react'
3
+
4
+ import { AnimatePresenceContext, AnimatePresenceContextProps } from './AnimatePresenceContext'
5
+ import { VariantLabels } from './types'
6
+
7
+ interface PresenceChildProps {
8
+ children: React.ReactElement<any>
9
+ isEntering: boolean | undefined
10
+ onExitComplete?: () => void
11
+ initial?: false | VariantLabels
12
+ exitVariant?: string | null
13
+ enterVariant?: string | null
14
+ presenceAffectsLayout: boolean
15
+ }
16
+
17
+ export const PresenceChild = ({
18
+ children,
19
+ initial,
20
+ isEntering,
21
+ onExitComplete,
22
+ exitVariant,
23
+ enterVariant,
24
+ presenceAffectsLayout,
25
+ }: PresenceChildProps) => {
26
+ const presenceChildren = useConstant(newChildrenMap)
27
+ const id = useId() || ''
28
+
29
+ const context = React.useMemo(
30
+ (): AnimatePresenceContextProps => {
31
+ return {
32
+ id,
33
+ initial,
34
+ isEntering,
35
+ exitVariant,
36
+ enterVariant,
37
+ onExitComplete: (childId: string) => {
38
+ presenceChildren.set(childId, true)
39
+ for (const isComplete of presenceChildren.values()) {
40
+ if (!isComplete) {
41
+ return // can stop searching when any is incomplete
42
+ }
43
+ }
44
+ onExitComplete?.()
45
+ },
46
+ register: (childId: string) => {
47
+ presenceChildren.set(childId, false)
48
+ return () => presenceChildren.delete(childId)
49
+ },
50
+ }
51
+ },
52
+ /**
53
+ * If the presence of a child affects the layout of the components around it,
54
+ * we want to make a new context value to ensure they get re-rendered
55
+ * so they can detect that layout change.
56
+ */
57
+ presenceAffectsLayout ? undefined : [isEntering, onExitComplete, exitVariant, enterVariant]
58
+ )
59
+
60
+ // cant this be a useEffect?
61
+ React.useMemo(() => {
62
+ presenceChildren.forEach((_, key) => presenceChildren.set(key, false))
63
+ }, [isEntering])
64
+
65
+ /**
66
+ * If there's no animated components to fire exit animations, we want to remove this
67
+ * component immediately.
68
+ */
69
+ React.useEffect(() => {
70
+ if (isEntering === false && !presenceChildren.size) {
71
+ onExitComplete?.()
72
+ }
73
+ }, [isEntering])
74
+
75
+ return (
76
+ <AnimatePresenceContext.Provider value={context}>{children}</AnimatePresenceContext.Provider>
77
+ )
78
+ }
79
+
80
+ function newChildrenMap(): Map<string, boolean> {
81
+ return new Map()
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './AnimatePresence'
2
+ export * from './useEntering'
3
+ export { AnimatePresenceContext } from './AnimatePresenceContext'
package/src/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ export interface Axis {
2
+ min: number
3
+ max: number
4
+ }
5
+
6
+ export interface Box {
7
+ x: Axis
8
+ y: Axis
9
+ }
10
+
11
+ export type VariantLabels = string | string[]
@@ -0,0 +1,43 @@
1
+ import { useId } from '@tamagui/core'
2
+ import { useContext, useEffect } from 'react'
3
+
4
+ import { AnimatePresenceContext, AnimatePresenceContextProps } from './AnimatePresenceContext'
5
+
6
+ export type SafeToRemove = () => void
7
+
8
+ export function useEntering(): [boolean | undefined, undefined | (() => void)] {
9
+ const context = useContext(AnimatePresenceContext)
10
+
11
+ if (context === null) {
12
+ return [undefined, undefined]
13
+ }
14
+
15
+ const { isEntering, onExitComplete, register } = context
16
+ const id = useId() || ''
17
+ const safeToRemove = () => onExitComplete?.(id)
18
+
19
+ // It's safe to call the following hooks conditionally (after an early return) because the context will always
20
+ // either be null or non-null for the lifespan of the component.
21
+
22
+ useEffect(() => {
23
+ register(id)
24
+ }, [])
25
+
26
+ return isEntering === false
27
+ ? [false, safeToRemove]
28
+ : isEntering === true
29
+ ? [true, safeToRemove]
30
+ : [undefined, safeToRemove]
31
+ }
32
+
33
+ /**
34
+ * Similar to `useEntering`, except `useIsEntering` simply returns whether or not the component is present.
35
+ * There is no `safeToRemove` function. ```
36
+ */
37
+ export function useIsEntering() {
38
+ return isEntering(useContext(AnimatePresenceContext))
39
+ }
40
+
41
+ export function isEntering(context: AnimatePresenceContextProps | null) {
42
+ return context === null ? undefined : context.isEntering
43
+ }