AnimatedHeader component

This commit is contained in:
minibits-cash
2026-01-27 15:23:43 +01:00
parent 346841c115
commit cfbb9a6335
5 changed files with 208 additions and 117 deletions
+112
View File
@@ -0,0 +1,112 @@
import React from 'react'
import { TextStyle, ViewStyle } from 'react-native'
import Animated, {
SharedValue,
useAnimatedStyle,
interpolate,
Extrapolation,
} from 'react-native-reanimated'
import { spacing, typography, useThemeColor } from '../theme'
import { translate, TxKeyPath } from '../i18n'
export interface AnimatedHeaderProps {
/**
* Title text to display.
*/
title?: string
/**
* Title text which is looked up via i18n.
*/
titleTx?: TxKeyPath
/**
* Shared value for scroll position tracking.
*/
scrollY: SharedValue<number>
/**
* The scroll distance over which the animation occurs.
* Defaults to spacing.screenHeight * 0.07
*/
scrollDistance?: number
/**
* Maximum header height when not scrolled.
* Defaults to spacing.screenHeight * 0.15
*/
maxHeight?: number
/**
* Minimum header height when fully scrolled.
* Defaults to spacing.screenHeight * 0.08
*/
minHeight?: number
}
/**
* AnimatedHeader component that provides a collapsible header with animated title.
* Use with Animated.ScrollView and useAnimatedScrollHandler to track scroll position.
*/
export function AnimatedHeader(props: AnimatedHeaderProps) {
const {
title,
titleTx,
scrollY,
scrollDistance = spacing.screenHeight * 0.07,
maxHeight = spacing.screenHeight * 0.15,
minHeight = spacing.screenHeight * 0.08,
} = props
const headerBg = useThemeColor('header')
const headerTitleColor = useThemeColor('headerTitle')
const animatedHeaderStyle = useAnimatedStyle(() => {
const height = interpolate(
scrollY.value,
[0, scrollDistance],
[maxHeight, minHeight],
Extrapolation.CLAMP
)
return { height }
})
const animatedTitleStyle = useAnimatedStyle(() => {
const scale = interpolate(
scrollY.value,
[0, scrollDistance],
[1, 0.75],
Extrapolation.CLAMP
)
const translateY = interpolate(
scrollY.value,
[0, scrollDistance],
[0, -spacing.extraLarge * 1.5],
Extrapolation.CLAMP
)
const opacity = interpolate(
scrollY.value,
[0, scrollDistance * 0.8],
[1, 0],
Extrapolation.CLAMP
)
return {
transform: [{ scale }, { translateY }],
opacity,
}
})
const titleContent = titleTx ? translate(titleTx) : title
return (
<Animated.View style={[animatedHeaderStyle, $headerContainer, { backgroundColor: headerBg }]}>
<Animated.Text style={[animatedTitleStyle, $headerTitle, { color: headerTitleColor }]}>
{titleContent}
</Animated.Text>
</Animated.View>
)
}
const $headerContainer: ViewStyle = {
alignItems: 'center',
}
const $headerTitle: TextStyle = {
fontSize: spacing.extraLarge,
fontFamily: typography.primary?.medium,
}
+1
View File
@@ -3,6 +3,7 @@ export * from "./AutoImage"
export * from "./Button"
export * from "./Card"
export * from "./Header"
export * from "./AnimatedHeader"
export * from "./Icon"
export * from "./ListItem"
export * from "./Screen"
+42 -22
View File
@@ -1,6 +1,10 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useState} from 'react'
import {Switch, TextStyle, View, ViewStyle, ScrollView} from 'react-native'
import React, {FC, useEffect, useState, useLayoutEffect} from 'react'
import {Switch, TextStyle, View, ViewStyle} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
} from 'react-native-reanimated'
import {colors, spacing, useThemeColor} from '../theme'
import {
ListItem,
@@ -12,8 +16,9 @@ import {
InfoModal,
BottomModal,
Button,
Header,
AnimatedHeader,
} from '../components'
import {useHeader} from '../utils/useHeader'
import {useStores} from '../models'
import AppError from '../utils/AppError'
import { KeyChain, MinibitsClient, log } from '../services'
@@ -25,12 +30,23 @@ type Props = StaticScreenProps<undefined>
export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
const navigation = useNavigation()
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => {
navigation.goBack()
}
})
const scrollY = useSharedValue(0)
const HEADER_SCROLL_DISTANCE = spacing.screenHeight * 0.07
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
header: () => (
<Header
titleTx="privacyScreen_title"
leftIcon="faArrowLeft"
onLeftPress={() => navigation.goBack()}
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
),
})
}, [])
const {userSettingsStore, walletProfileStore, walletStore, authStore} = useStores()
const [info, setInfo] = useState('')
@@ -180,16 +196,26 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
setError(e)
}
const headerBg = useThemeColor('header')
const iconColor = useThemeColor('textDim')
const headerTitle = useThemeColor('headerTitle')
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y
},
})
return (
<Screen style={$screen} preset='fixed'>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" tx="privacyScreen_title" style={{color: headerTitle}} />
</View>
<ScrollView style={$contentContainer}>
<AnimatedHeader
titleTx="privacyScreen_title"
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
<Animated.ScrollView
style={$contentContainer}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
{/*<Card
style={[$card, {marginTop: spacing.medium}]}
ContentComponent={
@@ -370,7 +396,7 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
}
/>
{isLoading && <Loading statusMessage={statusMessage} />}
</ScrollView>
</Animated.ScrollView>
{/*<BottomModal
isVisible={isTorModalVisible ? true : false}
ContentComponent={
@@ -398,12 +424,6 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
const $screen: ViewStyle = {}
const $headerContainer: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.15,
}
const $contentContainer: TextStyle = {
flex: 1,
marginTop: -spacing.extraLarge * 2,
+46 -28
View File
@@ -1,10 +1,14 @@
import {observer} from 'mobx-react-lite'
import React, {useState, useEffect, useRef} from 'react'
import {Platform, ScrollView, TextInput, TextStyle, View, ViewStyle} from 'react-native'
import React, {useState, useEffect, useRef, useLayoutEffect} from 'react'
import {Platform, TextInput, TextStyle, View, ViewStyle} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
} from 'react-native-reanimated'
import notifee, { AndroidImportance } from '@notifee/react-native'
import {spacing, useThemeColor, colors} from '../theme'
import {
Button,
Button,
Card,
Screen,
ListItem,
@@ -13,8 +17,9 @@ import {
InfoModal,
Loading,
BottomModal,
Header,
AnimatedHeader,
} from '../components'
import {useHeader} from '../utils/useHeader'
import {log} from '../services/logService'
import AppError, { Err } from '../utils/AppError'
import { useStores } from '../models'
@@ -33,11 +38,24 @@ type Props = StaticScreenProps<undefined>
export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: Props) {
const navigation = useNavigation()
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => navigation.goBack(),
})
const scrollY = useSharedValue(0)
const HEADER_SCROLL_DISTANCE = spacing.screenHeight * 0.07
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
header: () => (
<Header
titleTx="recoveryOptionsTitle"
leftIcon="faArrowLeft"
onLeftPress={() => navigation.goBack()}
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
),
})
}, [])
const {mintsStore, proofsStore} = useStores()
const unitRef = useRef<MintUnit>('sat')
@@ -297,22 +315,28 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
setMintBalanceToRecoverFrom(mintBalancesRef.current[0])
}
const headerBg = useThemeColor('header')
const inputBg = useThemeColor('background')
const mintsModalBg = useThemeColor('background')
const inputText = useThemeColor('text')
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y
},
})
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
preset="heading"
tx="recoveryOptionsTitle"
style={{color: 'white'}}
/>
</View>
<ScrollView style={$contentContainer}>
<AnimatedHeader
titleTx="recoveryOptionsTitle"
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
<Animated.ScrollView
style={$contentContainer}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
<Card
style={$card}
HeadingComponent={
@@ -413,10 +437,10 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
</>
}
/>
{isLoading && <Loading />}
{isLoading && <Loading />}
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</ScrollView>
{info && <InfoModal message={info} />}
</Animated.ScrollView>
<BottomModal
isVisible={isNotificationModalVisible ? true : false}
ContentComponent={
@@ -575,12 +599,6 @@ const $screen: ViewStyle = {
// flex: 1,
}
const $headerContainer: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.15,
}
const $contentContainer: TextStyle = {
//flex: 1,
marginTop: -spacing.extraLarge * 2,
+7 -67
View File
@@ -3,10 +3,7 @@ import React, {FC, useEffect, useLayoutEffect, useRef, useState} from 'react'
import {AppState, Platform, TextStyle, View, ViewStyle, useColorScheme} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from 'react-native-reanimated'
import notifee, { AuthorizationStatus } from '@notifee/react-native'
import messaging from '@react-native-firebase/messaging'
@@ -15,8 +12,8 @@ import {
HOT_UPDATER_API_KEY,
HOT_UPDATER_URL,
} from '@env'
import {ThemeCode, Themes, colors, spacing, typography, useThemeColor} from '../theme'
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon, Header} from '../components'
import {ThemeCode, Themes, colors, spacing, useThemeColor} from '../theme'
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon, Header, AnimatedHeader} from '../components'
import {useStores} from '../models'
import {translate} from '../i18n'
import { log } from '../services'
@@ -304,65 +301,22 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
const $itemRight = {color: useThemeColor('textDim')}
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
const colorScheme = useColorScheme()
const defaultThemeColor = colorScheme === 'dark' ? Themes[ThemeCode.DARK]?.color : Themes[ThemeCode.LIGHT]?.color
// Collapsible header animation
const HEADER_MAX_HEIGHT = spacing.screenHeight * 0.15
const HEADER_MIN_HEIGHT = spacing.screenHeight * 0.08
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y
},
})
const animatedHeaderStyle = useAnimatedStyle(() => {
const height = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT],
Extrapolation.CLAMP
)
return { height }
})
const animatedTitleStyle = useAnimatedStyle(() => {
const scale = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[1, 0.75],
Extrapolation.CLAMP
)
const translateY = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[0, -spacing.extraLarge * 1.5],
Extrapolation.CLAMP
)
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[1, 0],
Extrapolation.CLAMP
)
return {
transform: [{ scale }, { translateY }],
opacity,
}
})
return (
<Screen contentContainerStyle={$screen} preset='fixed'>
<Animated.View style={[animatedHeaderStyle, $headerContainer, {backgroundColor: headerBg}]}>
<Animated.Text
style={[animatedTitleStyle, $headerTitle, {color: headerTitle}]}
>
{translate('settingsScreen_title')}
</Animated.Text>
</Animated.View>
<AnimatedHeader
titleTx="settingsScreen_title"
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
<Animated.ScrollView
style={$contentContainer}
onScroll={scrollHandler}
@@ -637,20 +591,6 @@ const $screen: ViewStyle = {
}
const $headerContainer: ViewStyle = {
alignItems: 'center',
//justifyContent: 'flex-end',
//paddingBottom: spacing.extraLarge * 2 + spacing.medium,
//overflow: 'visible',
}
const $headerTitle: TextStyle = {
fontSize: spacing.extraLarge,
//lineHeight: 44,
fontFamily: typography.primary?.medium,
}
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,