Skip to content

Commit

Permalink
feat: Add main library code (#9)
Browse files Browse the repository at this point in the history
* Start writing main library code

* Finish adding library files
  • Loading branch information
MatiPl01 authored Oct 25, 2023
1 parent 05d0c16 commit a2596f9
Show file tree
Hide file tree
Showing 19 changed files with 642 additions and 1 deletion.
4 changes: 3 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
"name": "example",
"version": "1.0.0",
"dependencies": {
"@shopify/react-native-skia": "^0.1.214",
"expo": "~49.0.15",
"expo-status-bar": "~1.6.0",
"react": "18.2.0",
"react-native": "0.72.6"
"react-native": "0.72.6",
"react-native-reanimated": "^3.5.4"
},
"devDependencies": {
"@babel/core": "^7.20.0",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "1.0.0",
"author": "Mateusz Łopaciński <[email protected]>",
"devDependencies": {
"@shopify/react-native-skia": "^0.1.214",
"eslint": "^8.52.0",
"eslint-config-react-native-matipl01": "^1.0.2",
"husky": "^8.0.0",
Expand All @@ -11,6 +12,7 @@
"prettier": "^3.0.3",
"react": "^18.2.0",
"react-native": "^0.72.5",
"react-native-reanimated": "^3.5.4",
"syncpack": "^11.2.1",
"typescript": "^5.2.2"
},
Expand All @@ -26,6 +28,12 @@
]
},
"main": "index.js",
"peerDependencies": {
"@shopify/react-native-skia": ">=0.1.75",
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=2.0.0"
},
"private": true,
"publishConfig": {
"access": "public"
Expand Down
136 changes: 136 additions & 0 deletions src/components/ResponsiveText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Group, Rect, SkFont, TextProps } from '@shopify/react-native-skia';
import { memo, useMemo } from 'react';
import { runOnJS, SharedValue, useDerivedValue } from 'react-native-reanimated';

import { DEFAULT_FONT } from '@/constants';
import { useAnimatableValue } from '@/hooks';
import {
AnimatableProps,
AnimationSettings,
EllipsizeMode,
HorizontalAlignment,
PartialBy,
TextLineData,
VerticalAlignment
} from '@/types';
import {
getTextLinesAlignment,
getVerticalAlignmentOffset,
wrapText
} from '@/utils';

import TextLine from './TextLine';

type ResponsiveTextProps = PartialBy<TextProps, 'x' | 'y'> & {
ellipsizeMode?: EllipsizeMode;
height?: number;
numberOfLines?: number;
onMeasure?: (width: number, height: number) => void;
width?: number;
} & AnimatableProps<{
backgroundColor?: string;
font: SkFont;
horizontalAlignment?: HorizontalAlignment;
lineHeight?: number;
verticalAlignment?: VerticalAlignment;
}> &
(
| { animationProgress?: SharedValue<number> }
| { animationSettings?: AnimationSettings }
);

function ResponsiveText({
backgroundColor: backgroundColorProp = 'transparent',
children,
ellipsizeMode,
font = DEFAULT_FONT,
height = 0,
horizontalAlignment: horizontalAlignmentProp = 'left',
lineHeight: lineHeightProp,
numberOfLines,
onMeasure,
text = '',
verticalAlignment: verticalAlignmentProp = 'top',
width = 0,
x = 0,
y = 0,
...rest
}: ResponsiveTextProps) {
const fontSize = font.getSize();
// Create shared values from animatable props
const backgroundColor = useAnimatableValue(backgroundColorProp);

const lineHeight = useAnimatableValue(lineHeightProp ?? fontSize);
const horizontalAlignment = useAnimatableValue(horizontalAlignmentProp);
const verticalAlignment = useAnimatableValue(verticalAlignmentProp);

// Divide text into lines
const textLines = useMemo<Array<TextLineData>>(
() => wrapText(text, font, width, numberOfLines, ellipsizeMode),
[text, font, width, numberOfLines, ellipsizeMode]
);

// Calculate remaining values
const horizontalAlignmentOffsets = useDerivedValue(() => {
const alignments = getTextLinesAlignment(
textLines,
width,
horizontalAlignment.value
);

const textWidth = textLines.reduce(
(acc, { width: w }) => Math.max(acc, w),
0
);
const textHeight = textLines.length * lineHeight.value;

if (onMeasure) {
runOnJS(onMeasure)(textWidth, textHeight);
}

return alignments;
}, [textLines]);
const textHeight = useDerivedValue(
() =>
textLines.length * lineHeight.value - (lineHeight.value - 1.5 * fontSize)
);
const backgroundHeight = useDerivedValue(() =>
Math.max(textHeight.value, height)
);
const verticalAlignmentOffset = useDerivedValue(() =>
getVerticalAlignmentOffset(
textHeight.value,
height,
verticalAlignment.value
)
);

return (
<Group transform={[{ translateX: x }, { translateY: y }]}>
{backgroundColor && (
<Rect
color={backgroundColor}
height={backgroundHeight}
width={width}
y={0}
/>
)}
{textLines.map((line, i) => (
<TextLine
{...rest}
font={font}
fontSize={fontSize}
horizontalAlignmentOffsets={horizontalAlignmentOffsets}
index={i}
key={i}
lineHeight={lineHeight}
text={line.text}
verticalAlignmentOffset={verticalAlignmentOffset}>
{children}
</TextLine>
))}
</Group>
);
}

export default memo(ResponsiveText);
86 changes: 86 additions & 0 deletions src/components/TextLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Text, TextProps } from '@shopify/react-native-skia';
import {
interpolate,
SharedValue,
useAnimatedReaction,
useSharedValue,
withTiming
} from 'react-native-reanimated';

import { AnimationSettings } from '@/types';

type TextLineProps = Omit<TextProps, 'x' | 'y'> & {
animationProgress?: SharedValue<number>;
animationSettings?: AnimationSettings;
fontSize: number;
horizontalAlignmentOffsets: SharedValue<Array<number>>;
index: number;
lineHeight: SharedValue<number>;
verticalAlignmentOffset: SharedValue<number>;
};

export default function TextLine({
animationProgress,
animationSettings,
fontSize,
horizontalAlignmentOffsets,
index,
lineHeight,
verticalAlignmentOffset,
...rest
}: TextLineProps) {
const getCurrentX = () => {
'worklet';
return horizontalAlignmentOffsets.value[index] ?? 0;
};
const getCurrentY = () => {
'worklet';
return verticalAlignmentOffset.value + index * lineHeight.value + fontSize;
};

const startX = useSharedValue(getCurrentX());
const startY = useSharedValue(getCurrentY());
const prevTargetX = useSharedValue(getCurrentX());
const prevTargetY = useSharedValue(getCurrentY());
const x = useSharedValue(getCurrentX());
const y = useSharedValue(getCurrentY());

useAnimatedReaction(
() => ({
x: horizontalAlignmentOffsets.value[index] ?? 0,
y: verticalAlignmentOffset.value + index * lineHeight.value + fontSize
}),
target => {
if (animationSettings) {
const { onComplete, ...animation } = animationSettings;
x.value = withTiming(target.x, animation, onComplete);
y.value = withTiming(target.y, animation, onComplete);
} else if (!animationProgress) {
x.value = target.x;
y.value = target.y;
}
}
);

useAnimatedReaction(
() => animationProgress?.value ?? null,
progress => {
if (progress === null) return;

const targetX = getCurrentX();
const targetY = getCurrentY();

if (targetX !== prevTargetX.value || targetY !== prevTargetY.value) {
startX.value = x.value;
startY.value = y.value;
prevTargetX.value = targetX;
prevTargetY.value = targetY;
}

x.value = interpolate(progress, [0, 1], [startX.value, targetX]);
y.value = interpolate(progress, [0, 1], [startY.value, targetY]);
}
);

return <Text {...rest} x={x} y={y} />;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ResponsiveText } from './ResponsiveText';
12 changes: 12 additions & 0 deletions src/constants/font.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* eslint-disable new-cap */
import { FontStyle, Skia } from '@shopify/react-native-skia';
import { Platform } from 'react-native';

const familyName = Platform.select({ default: 'serif', ios: 'Helvetica' });
const fontSize = 32;

const fontMgr = Skia.FontMgr.System();

const typeface = fontMgr.matchFamilyStyle(familyName, FontStyle.Bold);

export const DEFAULT_FONT = Skia.Font(typeface, fontSize);
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './font';
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useAnimatableValue } from './useAnimatableValue';
16 changes: 16 additions & 0 deletions src/hooks/useAnimatableValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
isSharedValue,
SharedValue,
useDerivedValue
} from 'react-native-reanimated';

import { AnimatableValue } from '@/types';

export default function useAnimatableValue<T>(
value: AnimatableValue<T>
): SharedValue<T> {
return useDerivedValue(
() => (isSharedValue<T>(value) ? value.value : (value as T)),
[value]
);
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { ResponsiveText } from './components';
export type {
AnimatableProps,
AnimatableValue,
AnimationSettings,
EllipsizeMode,
HorizontalAlignment,
VerticalAlignment
} from './types';
20 changes: 20 additions & 0 deletions src/types/animations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { EasingFunction } from 'react-native';
import { EasingFunctionFactory, SharedValue } from 'react-native-reanimated';

type AnimationEasing = EasingFunction | EasingFunctionFactory;

export type AnimationSettings = {
duration?: number;
easing?: AnimationEasing;
onComplete?: (finished?: boolean) => void;
};

export type AnimatableValue<T> = T extends infer U | undefined
? U extends undefined
? never
: SharedValue<U> | U
: SharedValue<T> | T;

export type AnimatableProps<T extends object> = {
[K in keyof T]: AnimatableValue<T[K]>;
};
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './animations';
export * from './layout';
export * from './text';
export * from './utils';
8 changes: 8 additions & 0 deletions src/types/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type HorizontalAlignment =
| 'center'
| 'center-left'
| 'center-right'
| 'left'
| 'right';

export type VerticalAlignment = 'bottom' | 'center' | 'top';
6 changes: 6 additions & 0 deletions src/types/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type EllipsizeMode = 'clip' | 'head' | 'middle' | 'tail';

export type TextLineData = {
text: string;
width: number;
};
1 change: 1 addition & 0 deletions src/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
42 changes: 42 additions & 0 deletions src/utils/alignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { HorizontalAlignment, TextLineData, VerticalAlignment } from '@/types';

export const getTextLinesAlignment = (
lines: Array<TextLineData>,
width: number,
alignment: HorizontalAlignment = 'left'
): Array<number> => {
'worklet';
switch (alignment) {
case 'right':
return lines.map(line => width - line.width);
case 'center':
return lines.map(line => (width - line.width) / 2);
case 'center-left':
case 'center-right':
const longestLineWidth = Math.max(...lines.map(line => line.width));
return lines.map(line =>
alignment === 'center-left'
? (width - longestLineWidth) / 2
: (width + longestLineWidth) / 2 - line.width
);
case 'left':
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return lines.map(_ => 0);
}
};

export const getVerticalAlignmentOffset = (
componentHeight: number,
parentHeight = 0,
verticalAlignment: VerticalAlignment = 'top'
): number => {
'worklet';
switch (verticalAlignment) {
case 'top':
return 0;
case 'bottom':
return parentHeight - componentHeight;
case 'center':
return (parentHeight - componentHeight) / 2;
}
};
Loading

0 comments on commit a2596f9

Please sign in to comment.