@hzblj/kara-router 0.4.0 → 0.5.0

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/module/index.js +1 -0
  3. package/lib/module/index.js.map +1 -1
  4. package/lib/module/zoom-transition/index.js +8 -0
  5. package/lib/module/zoom-transition/index.js.map +1 -0
  6. package/lib/module/zoom-transition/use-zoom-transition-source-id.js +11 -0
  7. package/lib/module/zoom-transition/use-zoom-transition-source-id.js.map +1 -0
  8. package/lib/module/zoom-transition/with-zoom-transition.js +25 -0
  9. package/lib/module/zoom-transition/with-zoom-transition.js.map +1 -0
  10. package/lib/module/zoom-transition/zoom-transition-param.js +16 -0
  11. package/lib/module/zoom-transition/zoom-transition-param.js.map +1 -0
  12. package/lib/module/zoom-transition/zoom-transition.android.js +75 -0
  13. package/lib/module/zoom-transition/zoom-transition.android.js.map +1 -0
  14. package/lib/module/zoom-transition/zoom-transition.ios.js +47 -0
  15. package/lib/module/zoom-transition/zoom-transition.ios.js.map +1 -0
  16. package/lib/module/zoom-transition/zoom-transition.types.js +4 -0
  17. package/lib/module/zoom-transition/zoom-transition.types.js.map +1 -0
  18. package/lib/typescript/index.d.ts +1 -0
  19. package/lib/typescript/zoom-transition/index.d.ts +5 -0
  20. package/lib/typescript/zoom-transition/use-zoom-transition-source-id.d.ts +6 -0
  21. package/lib/typescript/zoom-transition/with-zoom-transition.d.ts +17 -0
  22. package/lib/typescript/zoom-transition/zoom-transition-param.d.ts +7 -0
  23. package/lib/typescript/zoom-transition/zoom-transition.d.ts +1 -0
  24. package/lib/typescript/zoom-transition/zoom-transition.ios.d.ts +2 -0
  25. package/lib/typescript/zoom-transition/zoom-transition.types.d.ts +93 -0
  26. package/package.json +4 -4
  27. package/src/index.ts +1 -0
  28. package/src/zoom-transition/index.ts +5 -0
  29. package/src/zoom-transition/use-zoom-transition-source-id.ts +8 -0
  30. package/src/zoom-transition/with-zoom-transition.ts +28 -0
  31. package/src/zoom-transition/zoom-transition-param.ts +14 -0
  32. package/src/zoom-transition/zoom-transition.android.tsx +81 -0
  33. package/src/zoom-transition/zoom-transition.ios.tsx +47 -0
  34. package/src/zoom-transition/zoom-transition.types.ts +108 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @hzblj/kara-router
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8](https://github.com/hzblj/kara-ui/pull/8) [`3b0a6fb`](https://github.com/hzblj/kara-ui/commit/3b0a6fbc49559720023fece2b5c6189ad4d88bc8) Thanks [@hzblj](https://github.com/hzblj)! - `ZoomTransition`: a push that grows out of the photo it was opened from and shrinks back into it.
8
+
9
+ - iOS 18: UIKit's zoom transition, with the interactive dismissal (swipe down, edge swipe, pinch) gated on the scroll views under the finger. Below iOS 18 the push stays a slide. Android: Material's container transform, both ways.
10
+ - Three pieces from `@hzblj/kara-ui/router`, tied by one identifier: `ZoomTransition.Source` on the list, `ZoomTransition.Enabler` and `ZoomTransition.Target` on the pushed screen; `useZoomTransitionSourceId`, `ZOOM_TRANSITION_PARAM`, `useZoomTransitionParam` and `withZoomTransition` (an Android fade under the transform) around them.
11
+ - Settings both platforms honour sit at the top (`scrim`); the rest is typed apart under `ios` (`dismissGesture`, `blur`, the target's `fit`) and `android` (`duration`, `easing`, `fade`, `path`, corner radii, the target's `fit` and `flex`).
12
+ - `ContextMenu`: with a `ZoomTransition.Source` in the trigger, a tap on the iOS preview zooms the preview's photo straight into the pushed screen. `previewTransition` (`zoom`, `pop`, `dismiss`) picks how the preview leaves.
13
+ - `DurationSize`, the keys of `duration`.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [[`3b0a6fb`](https://github.com/hzblj/kara-ui/commit/3b0a6fbc49559720023fece2b5c6189ad4d88bc8)]:
18
+ - @hzblj/kara-native@0.5.0
19
+ - @hzblj/kara-components@0.5.0
20
+ - @hzblj/kara-theme@0.5.0
21
+
22
+ ## 0.4.1
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [[`d14bdd3`](https://github.com/hzblj/kara-ui/commit/d14bdd37bede9fef80f4d3af4621ea5d60b2b16b)]:
27
+ - @hzblj/kara-native@0.4.1
28
+ - @hzblj/kara-theme@0.4.1
29
+ - @hzblj/kara-components@0.4.1
30
+
3
31
  ## 0.4.0
4
32
 
5
33
  ### Minor Changes
@@ -2,4 +2,5 @@
2
2
 
3
3
  export * from "./navigation-theme.js";
4
4
  export * from "./tabs/index.js";
5
+ export * from "./zoom-transition/index.js";
5
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":[],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA,cAAc,uBAAoB;AAClC,cAAc,iBAAQ","ignoreList":[]}
1
+ {"version":3,"names":[],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA,cAAc,uBAAoB;AAClC,cAAc,iBAAQ;AACtB,cAAc,4BAAmB","ignoreList":[]}
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+
3
+ export * from "./use-zoom-transition-source-id.js";
4
+ export * from "./with-zoom-transition.js";
5
+ export * from './zoom-transition';
6
+ export * from "./zoom-transition.types.js";
7
+ export * from "./zoom-transition-param.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../../src","sources":["zoom-transition/index.ts"],"mappings":";;AAAA,cAAc,oCAAiC;AAC/C,cAAc,2BAAwB;AACtC,cAAc,mBAAmB;AACjC,cAAc,4BAAyB;AACvC,cAAc,4BAAyB","ignoreList":[]}
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+
3
+ import { useId } from 'react';
4
+
5
+ /**
6
+ * An identifier for a `ZoomTransition.Source`, unique to this component instance and to `key` — the item it shows.
7
+ * Keyed by the item, a recycled list cell registers under a new identifier when it shows another item, so a detail
8
+ * opened from its previous item zooms back out plainly instead of into the wrong cell.
9
+ */
10
+ export const useZoomTransitionSourceId = key => `${useId()}${key}`;
11
+ //# sourceMappingURL=use-zoom-transition-source-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useId","useZoomTransitionSourceId","key"],"sourceRoot":"../../../src","sources":["zoom-transition/use-zoom-transition-source-id.ts"],"mappings":";;AAAA,SAAQA,KAAK,QAAO,OAAO;;AAE3B;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,yBAAyB,GAAIC,GAAW,IAAK,GAAGF,KAAK,CAAC,CAAC,GAAGE,GAAG,EAAE","ignoreList":[]}
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+
3
+ import { duration } from '@hzblj/kara-theme';
4
+ import { Platform } from 'react-native';
5
+ import { ZOOM_TRANSITION_PARAM } from "./zoom-transition-param.js";
6
+ const hasZoomSource = params => !!params && !!params[ZOOM_TRANSITION_PARAM];
7
+
8
+ /**
9
+ * `Stack.Screen` options for a screen the zoom pushes. On Android a push whose params carry `ZOOM_TRANSITION_PARAM`
10
+ * becomes a quick fade, so Material's container transform reads as the transition instead of racing the stack's own
11
+ * slide; any other push keeps the stack's animation. Your options win: set `animation` yourself to keep another one.
12
+ * iOS needs nothing — UIKit's zoom replaces the push animation.
13
+ */
14
+ export const withZoomTransition = options => props => {
15
+ const resolved = (typeof options === 'function' ? options(props) : options) ?? {};
16
+ if (Platform.OS !== 'android' || !hasZoomSource(props.route.params)) {
17
+ return resolved;
18
+ }
19
+ return {
20
+ animation: 'fade',
21
+ animationDuration: duration.d300,
22
+ ...resolved
23
+ };
24
+ };
25
+ //# sourceMappingURL=with-zoom-transition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["duration","Platform","ZOOM_TRANSITION_PARAM","hasZoomSource","params","withZoomTransition","options","props","resolved","OS","route","animation","animationDuration","d300"],"sourceRoot":"../../../src","sources":["zoom-transition/with-zoom-transition.ts"],"mappings":";;AAAA,SAAQA,QAAQ,QAAO,mBAAmB;AAC1C,SAAQC,QAAQ,QAAO,cAAc;AACrC,SAAQC,qBAAqB,QAAO,4BAAyB;AAM7D,MAAMC,aAAa,GAAIC,MAA0B,IAC/C,CAAC,CAACA,MAAM,IAAI,CAAC,CAAEA,MAAM,CAA6BF,qBAAqB,CAAC;;AAE1E;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMG,kBAAkB,GACHC,OAAsD,IAC/EC,KAAiB,IAAwC;EACxD,MAAMC,QAA2B,GAAG,CAAC,OAAOF,OAAO,KAAK,UAAU,GAAGA,OAAO,CAACC,KAAK,CAAC,GAAGD,OAAO,KAAK,CAAC,CAAC;EAEpG,IAAIL,QAAQ,CAACQ,EAAE,KAAK,SAAS,IAAI,CAACN,aAAa,CAACI,KAAK,CAACG,KAAK,CAACN,MAAM,CAAC,EAAE;IACnE,OAAOI,QAAQ;EACjB;EAEA,OAAO;IAACG,SAAS,EAAE,MAAM;IAAEC,iBAAiB,EAAEZ,QAAQ,CAACa,IAAI;IAAE,GAAGL;EAAQ,CAAC;AAC3E,CAAC","ignoreList":[]}
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ import { useLocalSearchParams } from 'expo-router';
4
+
5
+ /**
6
+ * The route param that carries a source's identifier to the screen it pushes — what `withZoomTransition` looks for.
7
+ * Push with `params: {[ZOOM_TRANSITION_PARAM]: sourceId}` and read it back with `useZoomTransitionParam`.
8
+ */
9
+ export const ZOOM_TRANSITION_PARAM = 'zoomTransitionSourceId';
10
+
11
+ /** The source identifier this screen was pushed with — for its `ZoomTransition.Enabler` and `Target`. */
12
+ export const useZoomTransitionParam = () => {
13
+ const value = useLocalSearchParams()[ZOOM_TRANSITION_PARAM];
14
+ return (Array.isArray(value) ? value[0] : value) || undefined;
15
+ };
16
+ //# sourceMappingURL=zoom-transition-param.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useLocalSearchParams","ZOOM_TRANSITION_PARAM","useZoomTransitionParam","value","Array","isArray","undefined"],"sourceRoot":"../../../src","sources":["zoom-transition/zoom-transition-param.ts"],"mappings":";;AAAA,SAAQA,oBAAoB,QAAO,aAAa;;AAEhD;AACA;AACA;AACA;AACA,OAAO,MAAMC,qBAAqB,GAAG,wBAAwB;;AAE7D;AACA,OAAO,MAAMC,sBAAsB,GAAGA,CAAA,KAA0B;EAC9D,MAAMC,KAAK,GAAGH,oBAAoB,CAAC,CAAC,CAACC,qBAAqB,CAAC;EAE3D,OAAO,CAACG,KAAK,CAACC,OAAO,CAACF,KAAK,CAAC,GAAGA,KAAK,CAAC,CAAC,CAAC,GAAGA,KAAK,KAAKG,SAAS;AAC/D,CAAC","ignoreList":[]}
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ import { ZoomTransitionEnabler as NativeZoomTransitionEnabler, ZoomTransitionSource as NativeZoomTransitionSource, ZoomTransitionTarget as NativeZoomTransitionTarget, reverseZoomTransition } from '@hzblj/kara-native/jetpack-compose';
4
+ import { duration, radii, useAppTheme } from '@hzblj/kara-theme';
5
+ import { useNavigation } from 'expo-router';
6
+ import { useEffect, useMemo } from 'react';
7
+ import { jsx as _jsx } from "react/jsx-runtime";
8
+ const Source = ({
9
+ android,
10
+ children,
11
+ identifier
12
+ }) => identifier ? /*#__PURE__*/_jsx(NativeZoomTransitionSource, {
13
+ cornerRadius: radii[android?.borderRadius ?? 'none'],
14
+ identifier: identifier,
15
+ children: children
16
+ }) : children;
17
+ const Target = ({
18
+ android,
19
+ children,
20
+ identifier
21
+ }) => {
22
+ const flex = android?.flex;
23
+ const style = useMemo(() => flex === undefined ? undefined : {
24
+ flex
25
+ }, [flex]);
26
+ if (!identifier) {
27
+ return children;
28
+ }
29
+ return /*#__PURE__*/_jsx(NativeZoomTransitionTarget, {
30
+ cornerRadius: radii[android?.borderRadius ?? 'none'],
31
+ identifier: identifier,
32
+ isFittedToSourceAspectRatio: android?.fit === 'photo',
33
+ style: style,
34
+ children: children
35
+ });
36
+ };
37
+
38
+ /**
39
+ * The target starts the way in natively once it is laid out. The way back cannot wait for the target to go: the pop is
40
+ * native, and by the time the screen unmounts it is already sliding away. So the transform is played back from
41
+ * `beforeRemove`, while the target is still on screen.
42
+ */
43
+ const Enabler = ({
44
+ android,
45
+ scrim,
46
+ sourceIdentifier
47
+ }) => {
48
+ const navigation = useNavigation();
49
+ const theme = useAppTheme();
50
+ useEffect(() => {
51
+ if (!sourceIdentifier) {
52
+ return;
53
+ }
54
+ return navigation.addListener('beforeRemove', () => {
55
+ void reverseZoomTransition(sourceIdentifier);
56
+ });
57
+ }, [navigation, sourceIdentifier]);
58
+ if (!sourceIdentifier) {
59
+ return null;
60
+ }
61
+ return /*#__PURE__*/_jsx(NativeZoomTransitionEnabler, {
62
+ easing: android?.easing,
63
+ fadeMode: android?.fade,
64
+ pathMotion: android?.path,
65
+ scrimColor: scrim ? theme.color(scrim) : undefined,
66
+ sourceIdentifier: sourceIdentifier,
67
+ transformDuration: android?.duration ? duration[android.duration] : undefined
68
+ });
69
+ };
70
+ export const ZoomTransition = {
71
+ Enabler,
72
+ Source,
73
+ Target
74
+ };
75
+ //# sourceMappingURL=zoom-transition.android.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["ZoomTransitionEnabler","NativeZoomTransitionEnabler","ZoomTransitionSource","NativeZoomTransitionSource","ZoomTransitionTarget","NativeZoomTransitionTarget","reverseZoomTransition","duration","radii","useAppTheme","useNavigation","useEffect","useMemo","jsx","_jsx","Source","android","children","identifier","cornerRadius","borderRadius","Target","flex","style","undefined","isFittedToSourceAspectRatio","fit","Enabler","scrim","sourceIdentifier","navigation","theme","addListener","easing","fadeMode","fade","pathMotion","path","scrimColor","color","transformDuration","ZoomTransition"],"sourceRoot":"../../../src","sources":["zoom-transition/zoom-transition.android.tsx"],"mappings":";;AAAA,SACEA,qBAAqB,IAAIC,2BAA2B,EACpDC,oBAAoB,IAAIC,0BAA0B,EAClDC,oBAAoB,IAAIC,0BAA0B,EAClDC,qBAAqB,QAChB,oCAAoC;AAC3C,SAAQC,QAAQ,EAAEC,KAAK,EAAEC,WAAW,QAAO,mBAAmB;AAC9D,SAAQC,aAAa,QAAO,aAAa;AACzC,SAAQC,SAAS,EAAEC,OAAO,QAAO,OAAO;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAQxC,MAAMC,MAAM,GAAGA,CAAC;EAACC,OAAO;EAAEC,QAAQ;EAAEC;AAAqC,CAAC,KACxEA,UAAU,gBACRJ,IAAA,CAACX,0BAA0B;EAACgB,YAAY,EAAEX,KAAK,CAACQ,OAAO,EAAEI,YAAY,IAAI,MAAM,CAAE;EAACF,UAAU,EAAEA,UAAW;EAAAD,QAAA,EACtGA;AAAQ,CACiB,CAAC,GAE7BA,QACD;AAEH,MAAMI,MAAM,GAAGA,CAAC;EAACL,OAAO;EAAEC,QAAQ;EAAEC;AAAqC,CAAC,KAAK;EAC7E,MAAMI,IAAI,GAAGN,OAAO,EAAEM,IAAI;EAC1B,MAAMC,KAAK,GAAGX,OAAO,CAAC,MAAOU,IAAI,KAAKE,SAAS,GAAGA,SAAS,GAAG;IAACF;EAAI,CAAE,EAAE,CAACA,IAAI,CAAC,CAAC;EAE9E,IAAI,CAACJ,UAAU,EAAE;IACf,OAAOD,QAAQ;EACjB;EAEA,oBACEH,IAAA,CAACT,0BAA0B;IACzBc,YAAY,EAAEX,KAAK,CAACQ,OAAO,EAAEI,YAAY,IAAI,MAAM,CAAE;IACrDF,UAAU,EAAEA,UAAW;IACvBO,2BAA2B,EAAET,OAAO,EAAEU,GAAG,KAAK,OAAQ;IACtDH,KAAK,EAAEA,KAAM;IAAAN,QAAA,EAEZA;EAAQ,CACiB,CAAC;AAEjC,CAAC;;AAED;AACA;AACA;AACA;AACA;AACA,MAAMU,OAAO,GAAGA,CAAC;EAACX,OAAO;EAAEY,KAAK;EAAEC;AAA4C,CAAC,KAAK;EAClF,MAAMC,UAAU,GAAGpB,aAAa,CAAC,CAAC;EAClC,MAAMqB,KAAK,GAAGtB,WAAW,CAAC,CAAC;EAE3BE,SAAS,CAAC,MAAM;IACd,IAAI,CAACkB,gBAAgB,EAAE;MACrB;IACF;IAEA,OAAOC,UAAU,CAACE,WAAW,CAAC,cAAc,EAAE,MAAM;MAClD,KAAK1B,qBAAqB,CAACuB,gBAAgB,CAAC;IAC9C,CAAC,CAAC;EACJ,CAAC,EAAE,CAACC,UAAU,EAAED,gBAAgB,CAAC,CAAC;EAElC,IAAI,CAACA,gBAAgB,EAAE;IACrB,OAAO,IAAI;EACb;EAEA,oBACEf,IAAA,CAACb,2BAA2B;IAC1BgC,MAAM,EAAEjB,OAAO,EAAEiB,MAAO;IACxBC,QAAQ,EAAElB,OAAO,EAAEmB,IAAK;IACxBC,UAAU,EAAEpB,OAAO,EAAEqB,IAAK;IAC1BC,UAAU,EAAEV,KAAK,GAAGG,KAAK,CAACQ,KAAK,CAACX,KAAK,CAAC,GAAGJ,SAAU;IACnDK,gBAAgB,EAAEA,gBAAiB;IACnCW,iBAAiB,EAAExB,OAAO,EAAET,QAAQ,GAAGA,QAAQ,CAACS,OAAO,CAACT,QAAQ,CAAC,GAAGiB;EAAU,CAC/E,CAAC;AAEN,CAAC;AAED,OAAO,MAAMiB,cAAuC,GAAG;EAACd,OAAO;EAAEZ,MAAM;EAAEM;AAAM,CAAC","ignoreList":[]}
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ import { ZoomTransitionEnabler as NativeZoomTransitionEnabler, ZoomTransitionSource as NativeZoomTransitionSource, ZoomTransitionTarget as NativeZoomTransitionTarget } from '@hzblj/kara-native/swift-ui';
4
+ import { useAppTheme } from '@hzblj/kara-theme';
5
+ import { jsx as _jsx } from "react/jsx-runtime";
6
+ const Source = ({
7
+ children,
8
+ identifier
9
+ }) => identifier ? /*#__PURE__*/_jsx(NativeZoomTransitionSource, {
10
+ identifier: identifier,
11
+ children: children
12
+ }) : children;
13
+ const Target = ({
14
+ children,
15
+ identifier,
16
+ ios
17
+ }) => identifier ? /*#__PURE__*/_jsx(NativeZoomTransitionTarget, {
18
+ identifier: identifier,
19
+ isAspectRatioMatched: ios?.fit !== 'target',
20
+ children: children
21
+ }) : children;
22
+
23
+ /** UIKit's zoom on the screen's own view controller; the source and target are looked up natively by identifier. */
24
+ const Enabler = ({
25
+ ios,
26
+ scrim,
27
+ sourceIdentifier
28
+ }) => {
29
+ const theme = useAppTheme();
30
+ if (!sourceIdentifier) {
31
+ return null;
32
+ }
33
+ const dismissGesture = ios?.dismissGesture ?? 'any';
34
+ return /*#__PURE__*/_jsx(NativeZoomTransitionEnabler, {
35
+ dimmingBlur: ios?.blur,
36
+ dimmingColor: scrim ? theme.color(scrim) : undefined,
37
+ isDismissalEnabled: dismissGesture !== 'none',
38
+ isHorizontalDismissalEnabled: dismissGesture === 'any',
39
+ sourceIdentifier: sourceIdentifier
40
+ });
41
+ };
42
+ export const ZoomTransition = {
43
+ Enabler,
44
+ Source,
45
+ Target
46
+ };
47
+ //# sourceMappingURL=zoom-transition.ios.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["ZoomTransitionEnabler","NativeZoomTransitionEnabler","ZoomTransitionSource","NativeZoomTransitionSource","ZoomTransitionTarget","NativeZoomTransitionTarget","useAppTheme","jsx","_jsx","Source","children","identifier","Target","ios","isAspectRatioMatched","fit","Enabler","scrim","sourceIdentifier","theme","dismissGesture","dimmingBlur","blur","dimmingColor","color","undefined","isDismissalEnabled","isHorizontalDismissalEnabled","ZoomTransition"],"sourceRoot":"../../../src","sources":["zoom-transition/zoom-transition.ios.tsx"],"mappings":";;AAAA,SACEA,qBAAqB,IAAIC,2BAA2B,EACpDC,oBAAoB,IAAIC,0BAA0B,EAClDC,oBAAoB,IAAIC,0BAA0B,QAC7C,6BAA6B;AACpC,SAAQC,WAAW,QAAO,mBAAmB;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAQ7C,MAAMC,MAAM,GAAGA,CAAC;EAACC,QAAQ;EAAEC;AAAqC,CAAC,KAC/DA,UAAU,gBAAGH,IAAA,CAACL,0BAA0B;EAACQ,UAAU,EAAEA,UAAW;EAAAD,QAAA,EAAEA;AAAQ,CAA6B,CAAC,GAAGA,QAAQ;AAErH,MAAME,MAAM,GAAGA,CAAC;EAACF,QAAQ;EAAEC,UAAU;EAAEE;AAA8B,CAAC,KACpEF,UAAU,gBACRH,IAAA,CAACH,0BAA0B;EAACM,UAAU,EAAEA,UAAW;EAACG,oBAAoB,EAAED,GAAG,EAAEE,GAAG,KAAK,QAAS;EAAAL,QAAA,EAC7FA;AAAQ,CACiB,CAAC,GAE7BA,QACD;;AAEH;AACA,MAAMM,OAAO,GAAGA,CAAC;EAACH,GAAG;EAAEI,KAAK;EAAEC;AAA4C,CAAC,KAAK;EAC9E,MAAMC,KAAK,GAAGb,WAAW,CAAC,CAAC;EAE3B,IAAI,CAACY,gBAAgB,EAAE;IACrB,OAAO,IAAI;EACb;EAEA,MAAME,cAAc,GAAGP,GAAG,EAAEO,cAAc,IAAI,KAAK;EAEnD,oBACEZ,IAAA,CAACP,2BAA2B;IAC1BoB,WAAW,EAAER,GAAG,EAAES,IAAK;IACvBC,YAAY,EAAEN,KAAK,GAAGE,KAAK,CAACK,KAAK,CAACP,KAAK,CAAC,GAAGQ,SAAU;IACrDC,kBAAkB,EAAEN,cAAc,KAAK,MAAO;IAC9CO,4BAA4B,EAAEP,cAAc,KAAK,KAAM;IACvDF,gBAAgB,EAAEA;EAAiB,CACpC,CAAC;AAEN,CAAC;AAED,OAAO,MAAMU,cAAuC,GAAG;EAACZ,OAAO;EAAEP,MAAM;EAAEG;AAAM,CAAC","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+
3
+ export {};
4
+ //# sourceMappingURL=zoom-transition.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../../src","sources":["zoom-transition/zoom-transition.types.ts"],"mappings":"","ignoreList":[]}
@@ -1,2 +1,3 @@
1
1
  export * from './navigation-theme';
2
2
  export * from './tabs';
3
+ export * from './zoom-transition';
@@ -0,0 +1,5 @@
1
+ export * from './use-zoom-transition-source-id';
2
+ export * from './with-zoom-transition';
3
+ export * from './zoom-transition';
4
+ export * from './zoom-transition.types';
5
+ export * from './zoom-transition-param';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * An identifier for a `ZoomTransition.Source`, unique to this component instance and to `key` — the item it shows.
3
+ * Keyed by the item, a recycled list cell registers under a new identifier when it shows another item, so a detail
4
+ * opened from its previous item zooms back out plainly instead of into the wrong cell.
5
+ */
6
+ export declare const useZoomTransitionSourceId: (key: string) => string;
@@ -0,0 +1,17 @@
1
+ type RouteProps = {
2
+ route: {
3
+ params?: object;
4
+ };
5
+ };
6
+ type ZoomAnimation = {
7
+ animation?: 'fade';
8
+ animationDuration?: number;
9
+ };
10
+ /**
11
+ * `Stack.Screen` options for a screen the zoom pushes. On Android a push whose params carry `ZOOM_TRANSITION_PARAM`
12
+ * becomes a quick fade, so Material's container transform reads as the transition instead of racing the stack's own
13
+ * slide; any other push keeps the stack's animation. Your options win: set `animation` yourself to keep another one.
14
+ * iOS needs nothing — UIKit's zoom replaces the push animation.
15
+ */
16
+ export declare const withZoomTransition: <TOptions extends object>(options?: TOptions | ((props: RouteProps) => TOptions)) => (props: RouteProps) => Partial<TOptions> & ZoomAnimation;
17
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The route param that carries a source's identifier to the screen it pushes — what `withZoomTransition` looks for.
3
+ * Push with `params: {[ZOOM_TRANSITION_PARAM]: sourceId}` and read it back with `useZoomTransitionParam`.
4
+ */
5
+ export declare const ZOOM_TRANSITION_PARAM = "zoomTransitionSourceId";
6
+ /** The source identifier this screen was pushed with — for its `ZoomTransition.Enabler` and `Target`. */
7
+ export declare const useZoomTransitionParam: () => string | undefined;
@@ -0,0 +1 @@
1
+ export * from './zoom-transition.ios';
@@ -0,0 +1,2 @@
1
+ import type { ZoomTransitionComponent } from './zoom-transition.types';
2
+ export declare const ZoomTransition: ZoomTransitionComponent;
@@ -0,0 +1,93 @@
1
+ import type { ColorToken, DurationSize, RadiiSize } from '@hzblj/kara-theme';
2
+ import type { ReactElement, ReactNode } from 'react';
3
+ export type ZoomTransitionSourceAndroidOptions = {
4
+ /** The corners the transform starts with — the source's own rounding (iOS reads the view's own). Default `none`. */
5
+ borderRadius?: RadiiSize;
6
+ };
7
+ export type ZoomTransitionSourceProps = {
8
+ /** Android-only settings. */
9
+ android?: ZoomTransitionSourceAndroidOptions;
10
+ /** A single view — the photo or card the zoom grows from. */
11
+ children: ReactElement;
12
+ /** Unset renders `children` alone: no zoom from here. Use `useZoomTransitionSourceId` for one unique per item. */
13
+ identifier?: string;
14
+ };
15
+ export type ZoomTransitionTargetIosOptions = {
16
+ /**
17
+ * What the source lands on. `source` (default): a rect of the source's aspect ratio centred in the target, so
18
+ * nothing is stretched. `target`: the whole target.
19
+ */
20
+ fit?: 'source' | 'target';
21
+ };
22
+ export type ZoomTransitionTargetAndroidOptions = {
23
+ /** The corners the transform ends with — the target's own rounding. Default `none`. */
24
+ borderRadius?: RadiiSize;
25
+ /**
26
+ * What the source lands on. `target` (default): the whole target. `photo`: the photo inside it — where a `contain`
27
+ * photo sits in a tall page — with the photo's cropped edges fading in on the way.
28
+ */
29
+ fit?: 'photo' | 'target';
30
+ /** The Android target is a real layout box (iOS adds none): give it `flex` when its child fills the parent. */
31
+ flex?: number;
32
+ };
33
+ export type ZoomTransitionTargetProps = {
34
+ /** Android-only settings. */
35
+ android?: ZoomTransitionTargetAndroidOptions;
36
+ /** A single view — what the source lands on in the pushed screen. */
37
+ children: ReactElement;
38
+ /** The source's identifier, handed over in the route params. Unset renders `children` alone. */
39
+ identifier?: string;
40
+ /** iOS-only settings. */
41
+ ios?: ZoomTransitionTargetIosOptions;
42
+ };
43
+ /**
44
+ * Which of UIKit's gestures may dismiss the screen back into its source. `any`: swipe down, edge swipe and pinch — a
45
+ * swipe only once no scroll view under the finger can still scroll that way. `vertical`: a downward swipe only, for
46
+ * content that pages sideways under its own gestures (a gallery). `none`: the back button only.
47
+ */
48
+ export type ZoomTransitionDismissGesture = 'any' | 'none' | 'vertical';
49
+ export type ZoomTransitionEnablerIosOptions = {
50
+ /** A system material blurring the screen behind the zoomed one. Default none. */
51
+ blur?: 'regular' | 'thick' | 'thin' | 'ultraThin';
52
+ /** Default `any`. */
53
+ dismissGesture?: ZoomTransitionDismissGesture;
54
+ };
55
+ export type ZoomTransitionEnablerAndroidOptions = {
56
+ /** Default `d450`. iOS's zoom is UIKit's own spring. */
57
+ duration?: DurationSize;
58
+ /** Default `emphasized` (Material 3, `0.2, 0, 0, 1`); `standard` is `0.4, 0, 0.2, 1`, `decelerate` `0, 0, 0, 1`. */
59
+ easing?: 'decelerate' | 'emphasized' | 'linear' | 'standard';
60
+ /** How the two screens' contents fade into each other inside the container. Default `cross`. */
61
+ fade?: 'cross' | 'in' | 'out' | 'through';
62
+ /** The path the container travels. Default `arc`. */
63
+ path?: 'arc' | 'linear';
64
+ };
65
+ export type ZoomTransitionEnablerProps = {
66
+ /** Android-only settings: Material's container transform. */
67
+ android?: ZoomTransitionEnablerAndroidOptions;
68
+ /** iOS-only settings: UIKit's zoom. */
69
+ ios?: ZoomTransitionEnablerIosOptions;
70
+ /** Laid over the screen behind the zoomed one. Unset: UIKit's own dimming on iOS, none on Android. */
71
+ scrim?: ColorToken;
72
+ /** The source's identifier, handed over in the route params. Unset renders nothing: the push stays the stack's own. */
73
+ sourceIdentifier?: string;
74
+ };
75
+ /**
76
+ * A push that grows out of the view it was opened from — a photo into its detail — and shrinks back into it. iOS 18:
77
+ * UIKit's zoom transition (`preferredTransition = .zoom`), interactive dismissal included; below iOS 18 the push stays
78
+ * the regular slide. Android: Material's container transform from the source to the target, both ways.
79
+ *
80
+ * Three pieces, tied by one identifier: `Source` around the view on the screen you push from, `Enabler` and `Target`
81
+ * on the pushed screen. Works with any Expo Router stack screen.
82
+ */
83
+ export type ZoomTransitionComponent = {
84
+ /**
85
+ * Turns the push of the screen it is rendered in into the zoom, and carries the animation settings. Render it
86
+ * unconditionally, before any loading state: on iOS it has to exist in the screen's first commit.
87
+ */
88
+ Enabler: (props: ZoomTransitionEnablerProps) => ReactNode;
89
+ /** Marks the view the zoom grows from. Inside a `ContextMenu`, a tap on the preview zooms the preview's photo. */
90
+ Source: (props: ZoomTransitionSourceProps) => ReactNode;
91
+ /** Marks what the source lands on in the pushed screen. It may render later than the enabler, once data arrives. */
92
+ Target: (props: ZoomTransitionTargetProps) => ReactNode;
93
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hzblj/kara-router",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Expo Router pieces of @hzblj/kara-ui: native tabs and a navigation theme mapped from the active kara theme.",
5
5
  "author": "Jan Blazej",
6
6
  "license": "MIT",
@@ -59,9 +59,9 @@
59
59
  ]
60
60
  },
61
61
  "dependencies": {
62
- "@hzblj/kara-components": "^0.4.0",
63
- "@hzblj/kara-native": "^0.4.0",
64
- "@hzblj/kara-theme": "^0.4.0"
62
+ "@hzblj/kara-components": "^0.5.0",
63
+ "@hzblj/kara-native": "^0.5.0",
64
+ "@hzblj/kara-theme": "^0.5.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "expo-router": "*",
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './navigation-theme'
2
2
  export * from './tabs'
3
+ export * from './zoom-transition'
@@ -0,0 +1,5 @@
1
+ export * from './use-zoom-transition-source-id'
2
+ export * from './with-zoom-transition'
3
+ export * from './zoom-transition'
4
+ export * from './zoom-transition.types'
5
+ export * from './zoom-transition-param'
@@ -0,0 +1,8 @@
1
+ import {useId} from 'react'
2
+
3
+ /**
4
+ * An identifier for a `ZoomTransition.Source`, unique to this component instance and to `key` — the item it shows.
5
+ * Keyed by the item, a recycled list cell registers under a new identifier when it shows another item, so a detail
6
+ * opened from its previous item zooms back out plainly instead of into the wrong cell.
7
+ */
8
+ export const useZoomTransitionSourceId = (key: string) => `${useId()}${key}`
@@ -0,0 +1,28 @@
1
+ import {duration} from '@hzblj/kara-theme'
2
+ import {Platform} from 'react-native'
3
+ import {ZOOM_TRANSITION_PARAM} from './zoom-transition-param'
4
+
5
+ type RouteProps = {route: {params?: object}}
6
+
7
+ type ZoomAnimation = {animation?: 'fade'; animationDuration?: number}
8
+
9
+ const hasZoomSource = (params: object | undefined) =>
10
+ !!params && !!(params as Record<string, unknown>)[ZOOM_TRANSITION_PARAM]
11
+
12
+ /**
13
+ * `Stack.Screen` options for a screen the zoom pushes. On Android a push whose params carry `ZOOM_TRANSITION_PARAM`
14
+ * becomes a quick fade, so Material's container transform reads as the transition instead of racing the stack's own
15
+ * slide; any other push keeps the stack's animation. Your options win: set `animation` yourself to keep another one.
16
+ * iOS needs nothing — UIKit's zoom replaces the push animation.
17
+ */
18
+ export const withZoomTransition =
19
+ <TOptions extends object>(options?: TOptions | ((props: RouteProps) => TOptions)) =>
20
+ (props: RouteProps): Partial<TOptions> & ZoomAnimation => {
21
+ const resolved: Partial<TOptions> = (typeof options === 'function' ? options(props) : options) ?? {}
22
+
23
+ if (Platform.OS !== 'android' || !hasZoomSource(props.route.params)) {
24
+ return resolved
25
+ }
26
+
27
+ return {animation: 'fade', animationDuration: duration.d300, ...resolved}
28
+ }
@@ -0,0 +1,14 @@
1
+ import {useLocalSearchParams} from 'expo-router'
2
+
3
+ /**
4
+ * The route param that carries a source's identifier to the screen it pushes — what `withZoomTransition` looks for.
5
+ * Push with `params: {[ZOOM_TRANSITION_PARAM]: sourceId}` and read it back with `useZoomTransitionParam`.
6
+ */
7
+ export const ZOOM_TRANSITION_PARAM = 'zoomTransitionSourceId'
8
+
9
+ /** The source identifier this screen was pushed with — for its `ZoomTransition.Enabler` and `Target`. */
10
+ export const useZoomTransitionParam = (): string | undefined => {
11
+ const value = useLocalSearchParams()[ZOOM_TRANSITION_PARAM]
12
+
13
+ return (Array.isArray(value) ? value[0] : value) || undefined
14
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ ZoomTransitionEnabler as NativeZoomTransitionEnabler,
3
+ ZoomTransitionSource as NativeZoomTransitionSource,
4
+ ZoomTransitionTarget as NativeZoomTransitionTarget,
5
+ reverseZoomTransition,
6
+ } from '@hzblj/kara-native/jetpack-compose'
7
+ import {duration, radii, useAppTheme} from '@hzblj/kara-theme'
8
+ import {useNavigation} from 'expo-router'
9
+ import {useEffect, useMemo} from 'react'
10
+ import type {
11
+ ZoomTransitionComponent,
12
+ ZoomTransitionEnablerProps,
13
+ ZoomTransitionSourceProps,
14
+ ZoomTransitionTargetProps,
15
+ } from './zoom-transition.types'
16
+
17
+ const Source = ({android, children, identifier}: ZoomTransitionSourceProps) =>
18
+ identifier ? (
19
+ <NativeZoomTransitionSource cornerRadius={radii[android?.borderRadius ?? 'none']} identifier={identifier}>
20
+ {children}
21
+ </NativeZoomTransitionSource>
22
+ ) : (
23
+ children
24
+ )
25
+
26
+ const Target = ({android, children, identifier}: ZoomTransitionTargetProps) => {
27
+ const flex = android?.flex
28
+ const style = useMemo(() => (flex === undefined ? undefined : {flex}), [flex])
29
+
30
+ if (!identifier) {
31
+ return children
32
+ }
33
+
34
+ return (
35
+ <NativeZoomTransitionTarget
36
+ cornerRadius={radii[android?.borderRadius ?? 'none']}
37
+ identifier={identifier}
38
+ isFittedToSourceAspectRatio={android?.fit === 'photo'}
39
+ style={style}
40
+ >
41
+ {children}
42
+ </NativeZoomTransitionTarget>
43
+ )
44
+ }
45
+
46
+ /**
47
+ * The target starts the way in natively once it is laid out. The way back cannot wait for the target to go: the pop is
48
+ * native, and by the time the screen unmounts it is already sliding away. So the transform is played back from
49
+ * `beforeRemove`, while the target is still on screen.
50
+ */
51
+ const Enabler = ({android, scrim, sourceIdentifier}: ZoomTransitionEnablerProps) => {
52
+ const navigation = useNavigation()
53
+ const theme = useAppTheme()
54
+
55
+ useEffect(() => {
56
+ if (!sourceIdentifier) {
57
+ return
58
+ }
59
+
60
+ return navigation.addListener('beforeRemove', () => {
61
+ void reverseZoomTransition(sourceIdentifier)
62
+ })
63
+ }, [navigation, sourceIdentifier])
64
+
65
+ if (!sourceIdentifier) {
66
+ return null
67
+ }
68
+
69
+ return (
70
+ <NativeZoomTransitionEnabler
71
+ easing={android?.easing}
72
+ fadeMode={android?.fade}
73
+ pathMotion={android?.path}
74
+ scrimColor={scrim ? theme.color(scrim) : undefined}
75
+ sourceIdentifier={sourceIdentifier}
76
+ transformDuration={android?.duration ? duration[android.duration] : undefined}
77
+ />
78
+ )
79
+ }
80
+
81
+ export const ZoomTransition: ZoomTransitionComponent = {Enabler, Source, Target}
@@ -0,0 +1,47 @@
1
+ import {
2
+ ZoomTransitionEnabler as NativeZoomTransitionEnabler,
3
+ ZoomTransitionSource as NativeZoomTransitionSource,
4
+ ZoomTransitionTarget as NativeZoomTransitionTarget,
5
+ } from '@hzblj/kara-native/swift-ui'
6
+ import {useAppTheme} from '@hzblj/kara-theme'
7
+ import type {
8
+ ZoomTransitionComponent,
9
+ ZoomTransitionEnablerProps,
10
+ ZoomTransitionSourceProps,
11
+ ZoomTransitionTargetProps,
12
+ } from './zoom-transition.types'
13
+
14
+ const Source = ({children, identifier}: ZoomTransitionSourceProps) =>
15
+ identifier ? <NativeZoomTransitionSource identifier={identifier}>{children}</NativeZoomTransitionSource> : children
16
+
17
+ const Target = ({children, identifier, ios}: ZoomTransitionTargetProps) =>
18
+ identifier ? (
19
+ <NativeZoomTransitionTarget identifier={identifier} isAspectRatioMatched={ios?.fit !== 'target'}>
20
+ {children}
21
+ </NativeZoomTransitionTarget>
22
+ ) : (
23
+ children
24
+ )
25
+
26
+ /** UIKit's zoom on the screen's own view controller; the source and target are looked up natively by identifier. */
27
+ const Enabler = ({ios, scrim, sourceIdentifier}: ZoomTransitionEnablerProps) => {
28
+ const theme = useAppTheme()
29
+
30
+ if (!sourceIdentifier) {
31
+ return null
32
+ }
33
+
34
+ const dismissGesture = ios?.dismissGesture ?? 'any'
35
+
36
+ return (
37
+ <NativeZoomTransitionEnabler
38
+ dimmingBlur={ios?.blur}
39
+ dimmingColor={scrim ? theme.color(scrim) : undefined}
40
+ isDismissalEnabled={dismissGesture !== 'none'}
41
+ isHorizontalDismissalEnabled={dismissGesture === 'any'}
42
+ sourceIdentifier={sourceIdentifier}
43
+ />
44
+ )
45
+ }
46
+
47
+ export const ZoomTransition: ZoomTransitionComponent = {Enabler, Source, Target}
@@ -0,0 +1,108 @@
1
+ import type {ColorToken, DurationSize, RadiiSize} from '@hzblj/kara-theme'
2
+ import type {ReactElement, ReactNode} from 'react'
3
+
4
+ /*
5
+ * Every prop at the top level works on both platforms. What only one platform can do lives under `ios` or `android`,
6
+ * typed apart, and is ignored on the other — so a call site reads which of its settings each platform honours.
7
+ */
8
+
9
+ export type ZoomTransitionSourceAndroidOptions = {
10
+ /** The corners the transform starts with — the source's own rounding (iOS reads the view's own). Default `none`. */
11
+ borderRadius?: RadiiSize
12
+ }
13
+
14
+ export type ZoomTransitionSourceProps = {
15
+ /** Android-only settings. */
16
+ android?: ZoomTransitionSourceAndroidOptions
17
+ /** A single view — the photo or card the zoom grows from. */
18
+ children: ReactElement
19
+ /** Unset renders `children` alone: no zoom from here. Use `useZoomTransitionSourceId` for one unique per item. */
20
+ identifier?: string
21
+ }
22
+
23
+ export type ZoomTransitionTargetIosOptions = {
24
+ /**
25
+ * What the source lands on. `source` (default): a rect of the source's aspect ratio centred in the target, so
26
+ * nothing is stretched. `target`: the whole target.
27
+ */
28
+ fit?: 'source' | 'target'
29
+ }
30
+
31
+ export type ZoomTransitionTargetAndroidOptions = {
32
+ /** The corners the transform ends with — the target's own rounding. Default `none`. */
33
+ borderRadius?: RadiiSize
34
+ /**
35
+ * What the source lands on. `target` (default): the whole target. `photo`: the photo inside it — where a `contain`
36
+ * photo sits in a tall page — with the photo's cropped edges fading in on the way.
37
+ */
38
+ fit?: 'photo' | 'target'
39
+ /** The Android target is a real layout box (iOS adds none): give it `flex` when its child fills the parent. */
40
+ flex?: number
41
+ }
42
+
43
+ export type ZoomTransitionTargetProps = {
44
+ /** Android-only settings. */
45
+ android?: ZoomTransitionTargetAndroidOptions
46
+ /** A single view — what the source lands on in the pushed screen. */
47
+ children: ReactElement
48
+ /** The source's identifier, handed over in the route params. Unset renders `children` alone. */
49
+ identifier?: string
50
+ /** iOS-only settings. */
51
+ ios?: ZoomTransitionTargetIosOptions
52
+ }
53
+
54
+ /**
55
+ * Which of UIKit's gestures may dismiss the screen back into its source. `any`: swipe down, edge swipe and pinch — a
56
+ * swipe only once no scroll view under the finger can still scroll that way. `vertical`: a downward swipe only, for
57
+ * content that pages sideways under its own gestures (a gallery). `none`: the back button only.
58
+ */
59
+ export type ZoomTransitionDismissGesture = 'any' | 'none' | 'vertical'
60
+
61
+ export type ZoomTransitionEnablerIosOptions = {
62
+ /** A system material blurring the screen behind the zoomed one. Default none. */
63
+ blur?: 'regular' | 'thick' | 'thin' | 'ultraThin'
64
+ /** Default `any`. */
65
+ dismissGesture?: ZoomTransitionDismissGesture
66
+ }
67
+
68
+ export type ZoomTransitionEnablerAndroidOptions = {
69
+ /** Default `d450`. iOS's zoom is UIKit's own spring. */
70
+ duration?: DurationSize
71
+ /** Default `emphasized` (Material 3, `0.2, 0, 0, 1`); `standard` is `0.4, 0, 0.2, 1`, `decelerate` `0, 0, 0, 1`. */
72
+ easing?: 'decelerate' | 'emphasized' | 'linear' | 'standard'
73
+ /** How the two screens' contents fade into each other inside the container. Default `cross`. */
74
+ fade?: 'cross' | 'in' | 'out' | 'through'
75
+ /** The path the container travels. Default `arc`. */
76
+ path?: 'arc' | 'linear'
77
+ }
78
+
79
+ export type ZoomTransitionEnablerProps = {
80
+ /** Android-only settings: Material's container transform. */
81
+ android?: ZoomTransitionEnablerAndroidOptions
82
+ /** iOS-only settings: UIKit's zoom. */
83
+ ios?: ZoomTransitionEnablerIosOptions
84
+ /** Laid over the screen behind the zoomed one. Unset: UIKit's own dimming on iOS, none on Android. */
85
+ scrim?: ColorToken
86
+ /** The source's identifier, handed over in the route params. Unset renders nothing: the push stays the stack's own. */
87
+ sourceIdentifier?: string
88
+ }
89
+
90
+ /**
91
+ * A push that grows out of the view it was opened from — a photo into its detail — and shrinks back into it. iOS 18:
92
+ * UIKit's zoom transition (`preferredTransition = .zoom`), interactive dismissal included; below iOS 18 the push stays
93
+ * the regular slide. Android: Material's container transform from the source to the target, both ways.
94
+ *
95
+ * Three pieces, tied by one identifier: `Source` around the view on the screen you push from, `Enabler` and `Target`
96
+ * on the pushed screen. Works with any Expo Router stack screen.
97
+ */
98
+ export type ZoomTransitionComponent = {
99
+ /**
100
+ * Turns the push of the screen it is rendered in into the zoom, and carries the animation settings. Render it
101
+ * unconditionally, before any loading state: on iOS it has to exist in the screen's first commit.
102
+ */
103
+ Enabler: (props: ZoomTransitionEnablerProps) => ReactNode
104
+ /** Marks the view the zoom grows from. Inside a `ContextMenu`, a tap on the preview zooms the preview's photo. */
105
+ Source: (props: ZoomTransitionSourceProps) => ReactNode
106
+ /** Marks what the source lands on in the pushed screen. It may render later than the enabler, once data arrives. */
107
+ Target: (props: ZoomTransitionTargetProps) => ReactNode
108
+ }