Skip to content

Commit

Permalink
feat(checkbox): add animated checkbox
Browse files Browse the repository at this point in the history
  • Loading branch information
craftzdog committed Nov 10, 2021
1 parent e86ccbb commit b570206
Show file tree
Hide file tree
Showing 15 changed files with 384 additions and 11 deletions.
4 changes: 2 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from 'react'
import AppContainer from './src/components/app-container'
import Main from './src/screens/main'
import Navigator from './src/'

export default function App() {
return (
<AppContainer>
<Main />
<Navigator />
</AppContainer>
)
}
9 changes: 5 additions & 4 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = function(api) {
api.cache(true);
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
};
};
plugins: ['react-native-reanimated/plugin']
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"react": "17.0.1",
"react-dom": "17.0.1",
"react-native": "0.64.3",
"react-native-gesture-handler": "^1.10.3",
"react-native-reanimated": "^2.2.4",
"react-native-safe-area-context": "^3.3.2",
"react-native-screens": "^3.9.0",
Expand Down
Binary file added src/assets/about-masthead.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/masthead.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/profile-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/takuya.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 105 additions & 0 deletions src/components/animated-checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useEffect, memo } from 'react'
import Animated, {
Easing,
useSharedValue,
useAnimatedProps,
withTiming,
interpolateColor
} from 'react-native-reanimated'
import Svg, { Path, Defs, ClipPath, G } from 'react-native-svg'
import AnimatedStroke from './animated-stroke'

const MARGIN = 10
const vWidth = 64 + MARGIN
const vHeight = 64 + MARGIN
const checkMarkPath =
'M15 31.1977C23.1081 36.4884 29.5946 43 29.5946 43C29.5946 43 37.5 25.5 69 1.5'
const outlineBoxPath =
'M24 0.5H40C48.5809 0.5 54.4147 2.18067 58.117 5.88299C61.8193 9.58532 63.5 15.4191 63.5 24V40C63.5 48.5809 61.8193 54.4147 58.117 58.117C54.4147 61.8193 48.5809 63.5 40 63.5H24C15.4191 63.5 9.58532 61.8193 5.88299 58.117C2.18067 54.4147 0.5 48.5809 0.5 40V24C0.5 15.4191 2.18067 9.58532 5.88299 5.88299C9.58532 2.18067 15.4191 0.5 24 0.5Z'

const AnimatedPath = Animated.createAnimatedComponent(Path)

interface Props {
checked?: boolean
highlightColor: string
checkmarkColor: string
boxOutlineColor: string
}

const AnimatedCheckbox = (props: Props) => {
const { checked, checkmarkColor, highlightColor, boxOutlineColor } = props

const progress = useSharedValue(0)

useEffect(() => {
progress.value = withTiming(checked ? 1 : 0, {
duration: checked ? 300 : 100,
easing: Easing.linear
})
}, [checked])

const animatedBoxProps = useAnimatedProps(
() => ({
stroke: interpolateColor(
Easing.bezier(0.16, 1, 0.3, 1)(progress.value),
[0, 1],
[boxOutlineColor, highlightColor],
'RGB'
),
fill: interpolateColor(
Easing.bezier(0.16, 1, 0.3, 1)(progress.value),
[0, 1],
['#00000000', highlightColor],
'RGB'
)
}),
[highlightColor, boxOutlineColor]
)

return (
<Svg
viewBox={[-MARGIN, -MARGIN, vWidth + MARGIN, vHeight + MARGIN].join(' ')}
>
<Defs>
<ClipPath id="clipPath">
<Path
fill="white"
stroke="gray"
strokeLinejoin="round"
strokeLinecap="round"
d={outlineBoxPath}
/>
</ClipPath>
</Defs>
<AnimatedStroke
progress={progress}
d={checkMarkPath}
stroke={highlightColor}
strokeWidth={10}
strokeLinejoin="round"
strokeLinecap="round"
strokeOpacity={checked || false ? 1 : 0}
/>
<AnimatedPath
d={outlineBoxPath}
strokeWidth={7}
strokeLinejoin="round"
strokeLinecap="round"
animatedProps={animatedBoxProps}
/>
<G clipPath="url(#clipPath)">
<AnimatedStroke
progress={progress}
d={checkMarkPath}
stroke={checkmarkColor}
strokeWidth={10}
strokeLinejoin="round"
strokeLinecap="round"
strokeOpacity={checked || false ? 1 : 0}
/>
</G>
</Svg>
)
}

export default AnimatedCheckbox
34 changes: 34 additions & 0 deletions src/components/animated-stroke.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { useRef, useState } from 'react'
import Animated, { Easing, useAnimatedProps } from 'react-native-reanimated'
import { Path, PathProps } from 'react-native-svg'

interface AnimatedStrokeProps extends PathProps {
progress: Animated.SharedValue<number>
}

const AnimatedPath = Animated.createAnimatedComponent(Path)

const AnimatedStroke = ({ progress, ...pathProps }: AnimatedStrokeProps) => {
const [length, setLength] = useState(0)
const ref = useRef<typeof AnimatedPath>(null)
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: Math.max(
0,
length - length * Easing.bezier(0.37, 0, 0.63, 1)(progress.value) - 0.1
)
}))

return (
<AnimatedPath
animatedProps={animatedProps}
// @ts-ignore
onLayout={() => setLength(ref.current!.getTotalLength())}
// @ts-ignore
ref={ref}
strokeDasharray={length}
{...pathProps}
/>
)
}

export default AnimatedStroke
102 changes: 102 additions & 0 deletions src/components/animated-task-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useEffect, memo } from 'react'
import { Pressable } from 'react-native'
import { Text, HStack, Box } from 'native-base'
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withTiming,
withSequence,
withDelay,
interpolateColor
} from 'react-native-reanimated'

interface Props {
strikethrough: boolean
textColor: string
inactiveTextColor: string
onPress?: () => void
children?: React.ReactNode
}

const AnimatedBox = Animated.createAnimatedComponent(Box)
const AnimatedHStack = Animated.createAnimatedComponent(HStack)
const AnimatedText = Animated.createAnimatedComponent(Text)

const AnimatedTaskLabel = memo((props: Props) => {
const { strikethrough, textColor, inactiveTextColor, onPress, children } =
props

const hstackOffset = useSharedValue(0)
const hstackAnimatedStyles = useAnimatedStyle(
() => ({
transform: [{ translateX: hstackOffset.value }]
}),
[strikethrough]
)
const textColorProgress = useSharedValue(0)
const textColorAnimatedStyles = useAnimatedStyle(
() => ({
color: interpolateColor(
textColorProgress.value,
[0, 1],
[textColor, inactiveTextColor]
)
}),
[strikethrough, textColor, inactiveTextColor]
)
const strikethroughWidth = useSharedValue(0)
const strikethroughAnimatedStyles = useAnimatedStyle(
() => ({
width: `${strikethroughWidth.value * 100}%`,
borderBottomColor: interpolateColor(
textColorProgress.value,
[0, 1],
[textColor, inactiveTextColor]
)
}),
[strikethrough, textColor, inactiveTextColor]
)

useEffect(() => {
const easing = Easing.out(Easing.quad)
if (strikethrough) {
hstackOffset.value = withSequence(
withTiming(4, { duration: 200, easing }),
withTiming(0, { duration: 200, easing })
)
strikethroughWidth.value = withTiming(1, { duration: 400, easing })
textColorProgress.value = withDelay(
1000,
withTiming(1, { duration: 400, easing })
)
} else {
strikethroughWidth.value = withTiming(0, { duration: 400, easing })
textColorProgress.value = withTiming(0, { duration: 400, easing })
}
})

return (
<Pressable onPress={onPress}>
<AnimatedHStack alignItems="center" style={[hstackAnimatedStyles]}>
<AnimatedText
fontSize={19}
noOfLines={1}
isTruncated
px={1}
style={[textColorAnimatedStyles]}
>
{children}
</AnimatedText>
<AnimatedBox
position="absolute"
h={1}
borderBottomWidth={1}
style={[strikethroughAnimatedStyles]}
/>
</AnimatedHStack>
</Pressable>
)
})

export default AnimatedTaskLabel
72 changes: 72 additions & 0 deletions src/components/task-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useCallback } from 'react'
import { Pressable } from 'react-native'
import {
Box,
HStack,
Text,
useTheme,
themeTools,
useColorModeValue
} from 'native-base'
import AnimatedCheckbox from './animated-checkbox'
import AnimatedTaskLabel from './animated-task-label'

interface Props {
isDone: boolean
onToggleCheckbox?: () => void
}

const TaskItem = (props: Props) => {
const { isDone, onToggleCheckbox } = props
const theme = useTheme()
const highlightColor = themeTools.getColor(
theme,
useColorModeValue('blue.500', 'blue.400')
)
const boxStroke = themeTools.getColor(
theme,
useColorModeValue('muted.300', 'muted.500')
)
const checkmarkColor = themeTools.getColor(
theme,
useColorModeValue('white', 'white')
)
const activeTextColor = themeTools.getColor(
theme,
useColorModeValue('darkText', 'lightText')
)
const doneTextColor = themeTools.getColor(
theme,
useColorModeValue('muted.400', 'muted.600')
)

return (
<HStack
alignItems="center"
w="full"
px={4}
py={2}
bg={useColorModeValue('warmGray.50', 'primary.900')}
>
<Box width={30} height={30} mr={2}>
<Pressable onPress={onToggleCheckbox}>
<AnimatedCheckbox
highlightColor={highlightColor}
checkmarkColor={checkmarkColor}
boxOutlineColor={boxStroke}
checked={isDone}
/>
</Pressable>
</Box>
<AnimatedTaskLabel
textColor={activeTextColor}
inactiveTextColor={doneTextColor}
strikethrough={isDone}
>
Task Item
</AnimatedTaskLabel>
</HStack>
)
}

export default TaskItem
17 changes: 17 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react'
import { createDrawerNavigator } from '@react-navigation/drawer'
import MainScreen from './screens/main-screen'
import AboutScreen from './screens/about-screen'

const Drawer = createDrawerNavigator()

const App = () => {
return (
<Drawer.Navigator initialRouteName="Main">
<Drawer.Screen name="Main" component={MainScreen} />
<Drawer.Screen name="About" component={AboutScreen} />
</Drawer.Navigator>
)
}

export default App
14 changes: 14 additions & 0 deletions src/screens/about-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react'
import { Box, Text, VStack } from 'native-base'

const AboutScreen = () => {
return (
<VStack>
<Box>
<Text>About</Text>
</Box>
</VStack>
)
}

export default AboutScreen
Loading

0 comments on commit b570206

Please sign in to comment.