react-native-image-collage 0.2.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +564 -0
  3. package/dist/CollageImage.d.ts +7 -0
  4. package/dist/CollageImage.d.ts.map +1 -0
  5. package/dist/CollageImage.js +49 -0
  6. package/dist/CollageTile.d.ts +18 -0
  7. package/dist/CollageTile.d.ts.map +1 -0
  8. package/dist/CollageTile.js +58 -0
  9. package/dist/CollageWithViewer.d.ts +4 -0
  10. package/dist/CollageWithViewer.d.ts.map +1 -0
  11. package/dist/CollageWithViewer.js +65 -0
  12. package/dist/ImageCollage.d.ts +4 -0
  13. package/dist/ImageCollage.d.ts.map +1 -0
  14. package/dist/ImageCollage.js +135 -0
  15. package/dist/ImageCollageWithViewer.d.ts +4 -0
  16. package/dist/ImageCollageWithViewer.d.ts.map +1 -0
  17. package/dist/ImageCollageWithViewer.js +59 -0
  18. package/dist/ImageViewer.d.ts +4 -0
  19. package/dist/ImageViewer.d.ts.map +1 -0
  20. package/dist/ImageViewer.js +76 -0
  21. package/dist/constants.d.ts +13 -0
  22. package/dist/constants.d.ts.map +1 -0
  23. package/dist/constants.js +13 -0
  24. package/dist/expo/createExpoImageRenderer.d.ts +9 -0
  25. package/dist/expo/createExpoImageRenderer.d.ts.map +1 -0
  26. package/dist/expo/createExpoImageRenderer.js +20 -0
  27. package/dist/expo/index.d.ts +15 -0
  28. package/dist/expo/index.d.ts.map +1 -0
  29. package/dist/expo/index.js +59 -0
  30. package/dist/hooks/useContainerWidth.d.ts +10 -0
  31. package/dist/hooks/useContainerWidth.d.ts.map +1 -0
  32. package/dist/hooks/useContainerWidth.js +22 -0
  33. package/dist/index.d.ts +12 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +35 -0
  36. package/dist/types.d.ts +93 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +2 -0
  39. package/dist/utils/imageSources.d.ts +11 -0
  40. package/dist/utils/imageSources.d.ts.map +1 -0
  41. package/dist/utils/imageSources.js +131 -0
  42. package/dist/utils/layoutHeight.d.ts +9 -0
  43. package/dist/utils/layoutHeight.d.ts.map +1 -0
  44. package/dist/utils/layoutHeight.js +41 -0
  45. package/dist/utils/renderCollageLayouts.d.ts +97 -0
  46. package/dist/utils/renderCollageLayouts.d.ts.map +1 -0
  47. package/dist/utils/renderCollageLayouts.js +183 -0
  48. package/dist/viewer/ImageCollageWithViewer.d.ts +4 -0
  49. package/dist/viewer/ImageCollageWithViewer.d.ts.map +1 -0
  50. package/dist/viewer/ImageCollageWithViewer.js +43 -0
  51. package/dist/viewer/ImageViewer.d.ts +5 -0
  52. package/dist/viewer/ImageViewer.d.ts.map +1 -0
  53. package/dist/viewer/ImageViewer.js +85 -0
  54. package/dist/viewer/index.d.ts +4 -0
  55. package/dist/viewer/index.d.ts.map +1 -0
  56. package/dist/viewer/index.js +8 -0
  57. package/package.json +68 -0
  58. package/src/CollageImage.tsx +41 -0
  59. package/src/CollageTile.tsx +69 -0
  60. package/src/CollageWithViewer.tsx +53 -0
  61. package/src/ImageCollage.tsx +168 -0
  62. package/src/constants.ts +11 -0
  63. package/src/expo/createExpoImageRenderer.tsx +43 -0
  64. package/src/expo/index.tsx +99 -0
  65. package/src/hooks/useContainerWidth.ts +29 -0
  66. package/src/index.ts +42 -0
  67. package/src/types.ts +120 -0
  68. package/src/utils/imageSources.ts +170 -0
  69. package/src/utils/layoutHeight.ts +54 -0
  70. package/src/utils/renderCollageLayouts.tsx +329 -0
  71. package/src/viewer/ImageCollageWithViewer.tsx +24 -0
  72. package/src/viewer/ImageViewer.tsx +93 -0
  73. package/src/viewer/index.ts +10 -0
@@ -0,0 +1,99 @@
1
+ import React from "react";
2
+ import { ImageCollage as BaseImageCollage } from "../ImageCollage";
3
+ import type {
4
+ ExpoImageCollageOptions,
5
+ ImageCollageProps,
6
+ ImageCollageWithViewerProps,
7
+ ImagePriority,
8
+ } from "../types";
9
+ import { ImageCollageWithViewer as BaseImageCollageWithViewer } from "../viewer/ImageCollageWithViewer";
10
+ import { createExpoImageRenderer } from "./createExpoImageRenderer";
11
+
12
+ export { CollageImage, renderCollageImage } from "../CollageImage";
13
+ export { CollageTile } from "../CollageTile";
14
+ export { CollageWithViewer } from "../CollageWithViewer";
15
+ export { ImageCollage as ImageCollague } from "../ImageCollage";
16
+ export { ImageViewer, createDefaultViewerRenderer } from "../viewer/ImageViewer";
17
+ export { ImageCollageWithViewer as BaseImageCollageWithViewer } from "../viewer/ImageCollageWithViewer";
18
+
19
+ export type {
20
+ CollageImageInput,
21
+ CollageImageRenderProps,
22
+ CollageImageRenderer,
23
+ CollageViewerRenderProps,
24
+ CollageViewerRenderer,
25
+ CollageWithViewerProps,
26
+ ExpoImageCollageOptions,
27
+ ImageCollageProps,
28
+ ImageCollageWithViewerProps,
29
+ ImagePriority,
30
+ ImageViewerImage,
31
+ ImageViewerProps,
32
+ NormalizedCollageImage,
33
+ } from "../types";
34
+
35
+ export {
36
+ ANDROID_RIPPLE,
37
+ DEFAULT_BLURHASH,
38
+ DEFAULT_BORDER_RADIUS,
39
+ DEFAULT_LAYOUT_MAX_HEIGHT,
40
+ DEFAULT_LAYOUT_MIN_HEIGHT,
41
+ DEFAULT_MAX_VISIBLE_IMAGES,
42
+ DEFAULT_PLACEHOLDER_BG,
43
+ DEFAULT_PLACEHOLDER_COLOR,
44
+ DEFAULT_SPACING,
45
+ } from "../constants";
46
+
47
+ export {
48
+ createExpoImageRenderer,
49
+ expoImageRenderer,
50
+ } from "./createExpoImageRenderer";
51
+
52
+ type ExpoCollageProps = ImageCollageProps & ExpoImageCollageOptions;
53
+
54
+ function buildExpoCollageProps({
55
+ blurhash,
56
+ prioritizeFirstImage = true,
57
+ renderImage,
58
+ getImagePriority,
59
+ ...props
60
+ }: ExpoCollageProps): ImageCollageProps {
61
+ return {
62
+ ...props,
63
+ renderImage: renderImage ?? createExpoImageRenderer({ blurhash }),
64
+ getImagePriority:
65
+ getImagePriority ??
66
+ ((index: number): ImagePriority =>
67
+ index === 0 && prioritizeFirstImage ? "high" : "normal"),
68
+ };
69
+ }
70
+
71
+ export function ImageCollage(props: ExpoCollageProps) {
72
+ return <BaseImageCollage {...buildExpoCollageProps(props)} />;
73
+ }
74
+
75
+ export function ImageCollageWithViewer({
76
+ blurhash,
77
+ prioritizeFirstImage,
78
+ renderImage,
79
+ getImagePriority,
80
+ viewerProps,
81
+ renderViewer,
82
+ onImagePress,
83
+ ...collageProps
84
+ }: ImageCollageWithViewerProps & ExpoImageCollageOptions) {
85
+ return (
86
+ <BaseImageCollageWithViewer
87
+ {...buildExpoCollageProps({
88
+ ...collageProps,
89
+ blurhash,
90
+ prioritizeFirstImage,
91
+ renderImage,
92
+ getImagePriority,
93
+ })}
94
+ viewerProps={viewerProps}
95
+ renderViewer={renderViewer}
96
+ onImagePress={onImagePress}
97
+ />
98
+ );
99
+ }
@@ -0,0 +1,29 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useWindowDimensions, type LayoutChangeEvent } from "react-native";
3
+
4
+ export function useContainerWidth({
5
+ width,
6
+ horizontalInset = 0,
7
+ }: {
8
+ width?: number;
9
+ horizontalInset?: number;
10
+ }) {
11
+ const { width: windowWidth } = useWindowDimensions();
12
+ const [measuredWidth, setMeasuredWidth] = useState<number | null>(null);
13
+
14
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
15
+ const nextWidth = event.nativeEvent.layout.width;
16
+ if (nextWidth > 0) {
17
+ setMeasuredWidth(nextWidth);
18
+ }
19
+ }, []);
20
+
21
+ const fallbackWidth = Math.max(0, windowWidth - horizontalInset);
22
+ const containerWidth = width ?? measuredWidth ?? fallbackWidth;
23
+
24
+ return {
25
+ containerWidth,
26
+ onLayout,
27
+ isMeasured: measuredWidth != null,
28
+ };
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ export { ImageCollage } from "./ImageCollage";
2
+ export { CollageWithViewer } from "./CollageWithViewer";
3
+ export { CollageImage, renderCollageImage } from "./CollageImage";
4
+ export { CollageTile } from "./CollageTile";
5
+
6
+ /** @deprecated Use `ImageCollage` instead. Kept for backwards compatibility. */
7
+ export { ImageCollage as ImageCollague } from "./ImageCollage";
8
+
9
+ export type {
10
+ CollageImageInput,
11
+ CollageImageRenderProps,
12
+ CollageImageRenderer,
13
+ CollageViewerRenderProps,
14
+ CollageViewerRenderer,
15
+ CollageWithViewerProps,
16
+ ImageCollageProps,
17
+ ImagePriority,
18
+ NormalizedCollageImage,
19
+ } from "./types";
20
+
21
+ export {
22
+ DEFAULT_PLACEHOLDER_COLOR,
23
+ DEFAULT_PLACEHOLDER_BG,
24
+ DEFAULT_SPACING,
25
+ DEFAULT_BORDER_RADIUS,
26
+ DEFAULT_LAYOUT_MIN_HEIGHT,
27
+ DEFAULT_LAYOUT_MAX_HEIGHT,
28
+ DEFAULT_MAX_VISIBLE_IMAGES,
29
+ ANDROID_RIPPLE,
30
+ } from "./constants";
31
+
32
+ export {
33
+ normalizeImageInput,
34
+ normalizeImages,
35
+ measureImageAspectRatio,
36
+ resolveImagesWithAspectRatios,
37
+ toViewerImages,
38
+ getRemoteUri,
39
+ } from "./utils/imageSources";
40
+
41
+ export { computeLayoutHeight } from "./utils/layoutHeight";
42
+ export { useContainerWidth } from "./hooks/useContainerWidth";
package/src/types.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { ReactElement } from "react";
2
+ import type {
3
+ ImageSourcePropType,
4
+ ImageStyle,
5
+ StyleProp,
6
+ ViewStyle,
7
+ } from "react-native";
8
+
9
+ export type ImagePriority = "low" | "normal" | "high";
10
+
11
+ /** Remote URL, RN image source, or object with optional aspect ratio. */
12
+ export type CollageImageInput =
13
+ | string
14
+ | ImageSourcePropType
15
+ | {
16
+ uri: string;
17
+ aspectRatio?: number;
18
+ };
19
+
20
+ export type NormalizedCollageImage = {
21
+ source: ImageSourcePropType;
22
+ aspectRatio?: number;
23
+ cacheKey: string;
24
+ remoteUri?: string;
25
+ };
26
+
27
+ export type CollageImageRenderProps = {
28
+ source: ImageSourcePropType;
29
+ remoteUri?: string;
30
+ style?: StyleProp<ImageStyle>;
31
+ priority?: ImagePriority;
32
+ transition: number;
33
+ };
34
+
35
+ export type CollageImageRenderer = (
36
+ props: CollageImageRenderProps,
37
+ ) => ReactElement;
38
+
39
+ export type ImageViewerImage = {
40
+ uri: string;
41
+ };
42
+
43
+ export type CollageViewerRenderProps = {
44
+ images: ImageViewerImage[];
45
+ visible: boolean;
46
+ imageIndex: number;
47
+ onRequestClose: () => void;
48
+ };
49
+
50
+ export type CollageViewerRenderer = (
51
+ props: CollageViewerRenderProps,
52
+ ) => ReactElement | null;
53
+
54
+ export type ImageCollageProps = {
55
+ images: CollageImageInput[] | null | undefined;
56
+ /** Fixed layout height; when omitted, height is derived from container width. */
57
+ height?: number;
58
+ /** Explicit container width; when omitted, width is measured via `onLayout`. */
59
+ width?: number;
60
+ /**
61
+ * @deprecated Prefer automatic `onLayout` sizing. Subtracted from screen width
62
+ * only before the container has been measured.
63
+ */
64
+ horizontalInset?: number;
65
+ borderRadius?: number;
66
+ spacing?: number;
67
+ /**
68
+ * Maximum tiles to show. When there are more images, the last visible tile
69
+ * displays a `+N` overlay for the remaining count.
70
+ *
71
+ * @example `maxVisibleImages={3}` with 4 images → 3-tile layout, third tile shows `+1`
72
+ * @example `maxVisibleImages={4}` with 5 images → 2×2 grid, fourth tile shows `+1`
73
+ */
74
+ maxVisibleImages?: number;
75
+ onImagePress?: (index: number) => void;
76
+ layoutMinHeight?: number;
77
+ layoutMaxHeight?: number;
78
+ placeholderColor?: string;
79
+ /** Measure missing aspect ratios before rendering. */
80
+ measureAspectRatios?: boolean;
81
+ /** Optional per-tile loading priority (used by custom renderers such as expo-image). */
82
+ getImagePriority?: (index: number) => ImagePriority;
83
+ /** Custom image renderer. Defaults to React Native `Image`. */
84
+ renderImage?: CollageImageRenderer;
85
+ style?: StyleProp<ViewStyle>;
86
+ };
87
+
88
+ export type CollageWithViewerProps = ImageCollageProps & {
89
+ renderViewer: CollageViewerRenderer;
90
+ onImagePress?: (index: number) => void;
91
+ };
92
+
93
+ export type ImageViewerProps = {
94
+ images: ImageViewerImage[];
95
+ visible: boolean;
96
+ imageIndex?: number;
97
+ onRequestClose: () => void;
98
+ swipeToCloseEnabled?: boolean;
99
+ doubleTapToZoomEnabled?: boolean;
100
+ presentationStyle?: "fullScreen" | "pageSheet" | "formSheet" | "overFullScreen";
101
+ showCloseButton?: boolean;
102
+ showIndexFooter?: boolean;
103
+ closeButtonLabel?: string;
104
+ };
105
+
106
+ export type ImageCollageWithViewerProps = Omit<ImageCollageProps, "onImagePress"> & {
107
+ viewerProps?: Omit<
108
+ ImageViewerProps,
109
+ "images" | "visible" | "imageIndex" | "onRequestClose"
110
+ >;
111
+ /** Override the built-in `react-native-image-viewing` viewer. */
112
+ renderViewer?: CollageViewerRenderer;
113
+ onImagePress?: (index: number) => void;
114
+ };
115
+
116
+ /** Expo-only collage options (`react-native-image-collage/expo`). */
117
+ export type ExpoImageCollageOptions = {
118
+ blurhash?: string;
119
+ prioritizeFirstImage?: boolean;
120
+ };
@@ -0,0 +1,170 @@
1
+ import { Image, type ImageSourcePropType } from "react-native";
2
+ import type { CollageImageInput, NormalizedCollageImage } from "../types";
3
+
4
+ function cacheKeyForSource(source: ImageSourcePropType): string {
5
+ if (typeof source === "number") {
6
+ return `asset-${source}`;
7
+ }
8
+
9
+ if (typeof source === "object" && source) {
10
+ if ("uri" in source && source.uri) {
11
+ return source.uri;
12
+ }
13
+
14
+ if ("testUri" in source && typeof source.testUri === "string") {
15
+ return source.testUri;
16
+ }
17
+ }
18
+
19
+ return JSON.stringify(source);
20
+ }
21
+
22
+ export function getRemoteUri(
23
+ source: ImageSourcePropType,
24
+ ): string | undefined {
25
+ if (typeof source === "number") {
26
+ return Image.resolveAssetSource(source)?.uri;
27
+ }
28
+
29
+ if (typeof source === "object" && source && "uri" in source && source.uri) {
30
+ return source.uri;
31
+ }
32
+
33
+ return undefined;
34
+ }
35
+
36
+ export function normalizeImageInput(
37
+ input: CollageImageInput,
38
+ ): NormalizedCollageImage | null {
39
+ if (input == null || input === "") {
40
+ return null;
41
+ }
42
+
43
+ if (typeof input === "string") {
44
+ const source = { uri: input };
45
+ return {
46
+ source,
47
+ aspectRatio: undefined,
48
+ cacheKey: input,
49
+ remoteUri: input,
50
+ };
51
+ }
52
+
53
+ if (typeof input === "number") {
54
+ const source = input;
55
+ const resolved = Image.resolveAssetSource(source);
56
+ const aspectRatio =
57
+ resolved?.width && resolved?.height
58
+ ? resolved.width / resolved.height
59
+ : undefined;
60
+
61
+ return {
62
+ source,
63
+ aspectRatio,
64
+ cacheKey: cacheKeyForSource(source),
65
+ remoteUri: resolved?.uri,
66
+ };
67
+ }
68
+
69
+ if (typeof input === "object" && input != null && "uri" in input) {
70
+ const uri = (input as { uri: unknown }).uri;
71
+ if (typeof uri === "string") {
72
+ const keys = Object.keys(input);
73
+ const isUriDescriptor = keys.every(
74
+ (key) => key === "uri" || key === "aspectRatio",
75
+ );
76
+
77
+ if (isUriDescriptor) {
78
+ const aspectRatio =
79
+ "aspectRatio" in input
80
+ ? (input as { aspectRatio?: number }).aspectRatio
81
+ : undefined;
82
+
83
+ return {
84
+ source: { uri },
85
+ aspectRatio,
86
+ cacheKey: uri,
87
+ remoteUri: uri,
88
+ };
89
+ }
90
+ }
91
+ }
92
+
93
+ const source = input as ImageSourcePropType;
94
+ const remoteUri = getRemoteUri(source);
95
+
96
+ return {
97
+ source,
98
+ aspectRatio: undefined,
99
+ cacheKey: cacheKeyForSource(source),
100
+ remoteUri,
101
+ };
102
+ }
103
+
104
+ export function normalizeImages(
105
+ images: CollageImageInput[] | null | undefined,
106
+ ): NormalizedCollageImage[] {
107
+ return (images ?? [])
108
+ .map(normalizeImageInput)
109
+ .filter((image): image is NormalizedCollageImage => image != null);
110
+ }
111
+
112
+ export function measureImageAspectRatio(
113
+ image: NormalizedCollageImage,
114
+ ): Promise<number> {
115
+ if (image.aspectRatio != null) {
116
+ return Promise.resolve(image.aspectRatio);
117
+ }
118
+
119
+ if (typeof image.source === "number") {
120
+ const resolved = Image.resolveAssetSource(image.source);
121
+ if (resolved?.width && resolved?.height) {
122
+ return Promise.resolve(resolved.width / resolved.height);
123
+ }
124
+
125
+ return Promise.reject(new Error("Unable to resolve local image dimensions"));
126
+ }
127
+
128
+ const uri = getRemoteUri(image.source);
129
+ if (!uri) {
130
+ return Promise.reject(new Error("Unable to measure image without a URI"));
131
+ }
132
+
133
+ return new Promise((resolve, reject) => {
134
+ Image.getSize(
135
+ uri,
136
+ (width, height) => resolve(width / height),
137
+ reject,
138
+ );
139
+ });
140
+ }
141
+
142
+ export async function resolveImagesWithAspectRatios(
143
+ images: NormalizedCollageImage[],
144
+ ): Promise<NormalizedCollageImage[]> {
145
+ return Promise.all(
146
+ images.map(async (image) => {
147
+ if (image.aspectRatio != null) {
148
+ return image;
149
+ }
150
+
151
+ try {
152
+ const aspectRatio = await measureImageAspectRatio(image);
153
+ return { ...image, aspectRatio };
154
+ } catch {
155
+ return image;
156
+ }
157
+ }),
158
+ );
159
+ }
160
+
161
+ export function toViewerImages(
162
+ images: NormalizedCollageImage[],
163
+ ): { uri: string }[] {
164
+ return images
165
+ .map((image) => {
166
+ const uri = image.remoteUri ?? getRemoteUri(image.source);
167
+ return uri ? { uri } : null;
168
+ })
169
+ .filter((image): image is { uri: string } => image != null);
170
+ }
@@ -0,0 +1,54 @@
1
+ import type { NormalizedCollageImage } from "../types";
2
+
3
+ function heightRatioForImageCount(count: number) {
4
+ if (count <= 1) return 0.9;
5
+ if (count === 2) return 0.75;
6
+ if (count === 3) return 0.9;
7
+ if (count === 4) return 1.0;
8
+ return 1.05;
9
+ }
10
+
11
+ function averageAspectRatio(images: NormalizedCollageImage[]): number | undefined {
12
+ const ratios = images
13
+ .map((image) => image.aspectRatio)
14
+ .filter((ratio): ratio is number => ratio != null && ratio > 0);
15
+
16
+ if (!ratios.length) {
17
+ return undefined;
18
+ }
19
+
20
+ return ratios.reduce((sum, ratio) => sum + ratio, 0) / ratios.length;
21
+ }
22
+
23
+ export function computeLayoutHeight({
24
+ contentWidth,
25
+ images,
26
+ height,
27
+ layoutMinHeight,
28
+ layoutMaxHeight,
29
+ }: {
30
+ contentWidth: number;
31
+ images: NormalizedCollageImage[];
32
+ height?: number;
33
+ layoutMinHeight: number;
34
+ layoutMaxHeight: number;
35
+ }): number {
36
+ if (height != null) {
37
+ return Math.max(layoutMinHeight, Math.min(height, layoutMaxHeight));
38
+ }
39
+
40
+ const count = images.length;
41
+ let ratio = heightRatioForImageCount(count);
42
+
43
+ if (count === 1 && images[0]?.aspectRatio) {
44
+ ratio = 1 / images[0].aspectRatio;
45
+ } else if (count === 2) {
46
+ const averageRatio = averageAspectRatio(images);
47
+ if (averageRatio) {
48
+ ratio = 1 / averageRatio;
49
+ }
50
+ }
51
+
52
+ const computedHeight = Math.round(contentWidth * ratio);
53
+ return Math.max(layoutMinHeight, Math.min(computedHeight, layoutMaxHeight));
54
+ }