From 3427b613aa4c7b405a9fa65ceadb088e90b23939 Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Thu, 17 Dec 2020 09:44:03 +0100 Subject: [PATCH] Using tab-view Cannot scroll separately --- package.json | 3 +- src/components/ParseContent.tsx | 34 ++--- .../Timelines/Timeline/Shared/Poll.tsx | 34 ++--- src/screens/Me/Root/Login.tsx | 5 +- src/screens/Shared/Account.tsx | 80 ++++++++++-- src/screens/Shared/Account/Header.tsx | 25 ++-- src/screens/Shared/Account/Information.tsx | 37 ++++-- src/screens/Shared/Account/Nav.tsx | 101 +++++++++++++++ .../Shared/Account/SegmentedControl.tsx | 81 ++++++++++++ src/screens/Shared/Account/Toots.tsx | 117 +++++++----------- src/screens/Shared/Compose.tsx | 11 +- src/screens/Shared/Compose/Attachments.tsx | 5 +- src/screens/Shared/sharedScreens.tsx | 23 ++-- src/utils/fetches/timelineFetch.ts | 65 +++++++--- yarn.lock | 14 +-- 15 files changed, 462 insertions(+), 173 deletions(-) create mode 100644 src/screens/Shared/Account/Nav.tsx create mode 100644 src/screens/Shared/Account/SegmentedControl.tsx diff --git a/package.json b/package.json index 0338f210..394586b5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-native-screens": "~2.15.0", "react-native-shimmer-placeholder": "^2.0.6", "react-native-svg": "12.1.0", + "react-native-tab-view": "^2.15.2", "react-native-toast-message": "^1.3.4", "react-native-webview": "11.0.0", "react-navigation": "^4.4.3", @@ -70,4 +71,4 @@ "typescript": "~4.0.0" }, "private": true -} \ No newline at end of file +} diff --git a/src/components/ParseContent.tsx b/src/components/ParseContent.tsx index 3d2a5419..b98bb2ed 100644 --- a/src/components/ParseContent.tsx +++ b/src/components/ParseContent.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react' -import { Pressable, Text } from 'react-native' +import { Pressable, Text, View } from 'react-native' import HTMLView from 'react-native-htmlview' import { useNavigation } from '@react-navigation/native' @@ -98,6 +98,12 @@ const renderNode = ({ ) } + } else { + if (node.name === 'p') { + if (!node.children.length) { + return // bug when the tag is empty + } + } } } @@ -134,26 +140,24 @@ const ParseContent: React.FC = ({ }), [] ) - const textComponent = useCallback( - ({ children }) => - emojis && children ? ( - - ) : ( - {children} - ), - [] - ) + const textComponent = useCallback(({ children }) => { + return emojis && children ? ( + + ) : ( + {children} + ) + }, []) const rootComponent = useCallback(({ children }) => { const { theme } = useTheme() const [textLoaded, setTextLoaded] = useState(false) const [totalLines, setTotalLines] = useState() const [lineHeight, setLineHeight] = useState() const [shownLines, setShownLines] = useState(numberOfLines) - + // console.log(children) return ( <> = ({ queryKey, status: { poll } }) => { const pollExpiration = useMemo(() => { // how many voted - if (poll.expired) { + if (poll!.expired) { return ( 投票已结束 @@ -113,20 +113,20 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { } else { return ( - 截止至{relativeTime(poll.expires_at)} + {relativeTime(poll!.expires_at)}截止 ) } }, []) const [singleOptions, setSingleOptions] = useState({ - ...[false, false, false, false].slice(0, poll.options.length) + ...[false, false, false, false].slice(0, poll!.options.length) }) const [multipleOptions, setMultipleOptions] = useState({ - ...[false, false, false, false].slice(0, poll.options.length) + ...[false, false, false, false].slice(0, poll!.options.length) }) const isSelected = (index: number) => { - if (poll.multiple) { + if (poll!.multiple) { return multipleOptions[index] ? 'check-square' : 'square' } else { return singleOptions[index] ? 'check-circle' : 'circle' @@ -135,28 +135,28 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { return ( - {poll.options.map((option, index) => - poll.voted ? ( + {poll!.options.map((option, index) => + poll!.voted ? ( - {poll.own_votes!.includes(index) && ( + {poll!.own_votes!.includes(index) && ( )} - {Math.round((option.votes_count / poll.votes_count) * 100)}% + {Math.round((option.votes_count / poll!.votes_count) * 100)}% @@ -165,7 +165,7 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { styles.background, { width: `${Math.round( - (option.votes_count / poll.votes_count) * 100 + (option.votes_count / poll!.votes_count) * 100 )}%`, backgroundColor: theme.border } @@ -177,7 +177,7 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { { - if (poll.multiple) { + if (poll!.multiple) { setMultipleOptions({ ...multipleOptions, [index]: !multipleOptions[index] @@ -189,7 +189,7 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { index === 1, index === 2, index === 3 - ].slice(0, poll.options.length) + ].slice(0, poll!.options.length) }) } }} @@ -203,7 +203,7 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { @@ -227,7 +227,7 @@ const TimelinePoll: React.FC = ({ queryKey, status: { poll } }) => { )} - 已投{poll.voters_count}人{' • '} + 已投{poll.voters_count || 0}人{' • '} {pollExpiration} diff --git a/src/screens/Me/Root/Login.tsx b/src/screens/Me/Root/Login.tsx index 9138b137..f624f453 100644 --- a/src/screens/Me/Root/Login.tsx +++ b/src/screens/Me/Root/Login.tsx @@ -21,9 +21,10 @@ import { useTranslation } from 'react-i18next' import { StyleConstants } from '@utils/styles/constants' import { ButtonRow } from '@components/Button' import ParseContent from '@root/components/ParseContent' -import ShimmerPlaceholder from 'react-native-shimmer-placeholder' +import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder' import { Feather } from '@expo/vector-icons' import { applicationFetch } from '@root/utils/fetches/applicationFetch' +import { LinearGradient } from 'expo-linear-gradient' const Login: React.FC = () => { const { t } = useTranslation('meRoot') @@ -36,6 +37,8 @@ const Login: React.FC = () => { clientSecret: string }>() + const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient) + const instanceQuery = useQuery( ['Instance', { instanceDomain }], instanceFetch, diff --git a/src/screens/Shared/Account.tsx b/src/screens/Shared/Account.tsx index e5afcc44..2c0c1ac5 100644 --- a/src/screens/Shared/Account.tsx +++ b/src/screens/Shared/Account.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { ScrollView } from 'react-native' +import React, { createContext, Dispatch, useReducer, useRef } from 'react' +import { Animated, ScrollView } from 'react-native' // import * as relationshipsSlice from 'src/stacks/common/relationshipsSlice' @@ -8,6 +8,8 @@ import { accountFetch } from '@utils/fetches/accountFetch' import AccountToots from '@screens/Shared/Account/Toots' import AccountHeader from '@screens/Shared/Account/Header' import AccountInformation from '@screens/Shared/Account/Information' +import AccountNav from './Account/Nav' +import AccountSegmentedControl from './Account/SegmentedControl' // Moved account example: https://m.cmx.im/web/accounts/27812 @@ -19,6 +21,53 @@ export interface Props { } } +export type AccountState = { + headerRatio: number + informationLayout?: { + y: number + height: number + } + segmentedIndex: number +} +export type AccountAction = + | { + type: 'headerRatio' + payload: AccountState['headerRatio'] + } + | { + type: 'informationLayout' + payload: AccountState['informationLayout'] + } + | { + type: 'segmentedIndex' + payload: AccountState['segmentedIndex'] + } +const AccountInitialState: AccountState = { + headerRatio: 0.4, + informationLayout: { height: 0, y: 100 }, + segmentedIndex: 0 +} +const accountReducer = ( + state: AccountState, + action: AccountAction +): AccountState => { + switch (action.type) { + case 'headerRatio': + return { ...state, headerRatio: action.payload } + case 'informationLayout': + return { ...state, informationLayout: action.payload } + case 'segmentedIndex': + return { ...state, segmentedIndex: action.payload } + default: + throw new Error('Unexpected action') + } +} +type ContextType = { + accountState: AccountState + accountDispatch: Dispatch +} +export const AccountContext = createContext({} as ContextType) + const ScreenSharedAccount: React.FC = ({ route: { params: { id } @@ -27,13 +76,30 @@ const ScreenSharedAccount: React.FC = ({ const { data } = useQuery(['Account', { id }], accountFetch) // const stateRelationships = useSelector(relationshipsState) + const scrollY = useRef(new Animated.Value(0)).current + const [accountState, accountDispatch] = useReducer( + accountReducer, + AccountInitialState + ) return ( - - - - - + + + + + + + + + ) } diff --git a/src/screens/Shared/Account/Header.tsx b/src/screens/Shared/Account/Header.tsx index 13f07299..086998ed 100644 --- a/src/screens/Shared/Account/Header.tsx +++ b/src/screens/Shared/Account/Header.tsx @@ -1,37 +1,44 @@ -import React, { useEffect, useRef } from 'react' +import React, { useContext, useEffect, useRef } from 'react' import { Animated, Dimensions, Image, StyleSheet } from 'react-native' +import { AccountContext } from '../Account' export interface Props { uri?: Mastodon.Account['header'] limitHeight?: boolean } -const limitRatio = 0.4 - const AccountHeader: React.FC = ({ uri, limitHeight = false }) => { + const { accountState, accountDispatch } = useContext(AccountContext) + useEffect(() => { if (uri) { if (uri.includes('/headers/original/missing.png')) { - animateNewSize(limitRatio) + animateNewSize(accountState.headerRatio) } else { Image.getSize( uri, (width, height) => { - animateNewSize(limitHeight ? limitRatio : height / width) + if (!limitHeight) { + accountDispatch({ type: 'headerRatio', payload: height / width }) + } + animateNewSize( + limitHeight ? accountState.headerRatio : height / width + ) }, () => { - animateNewSize(limitRatio) + animateNewSize(accountState.headerRatio) } ) } } else { - animateNewSize(limitRatio) + animateNewSize(accountState.headerRatio) } }, [uri]) const windowWidth = Dimensions.get('window').width - const imageHeight = useRef(new Animated.Value(windowWidth * limitRatio)) - .current + const imageHeight = useRef( + new Animated.Value(windowWidth * accountState.headerRatio) + ).current const animateNewSize = (ratio: number) => { Animated.timing(imageHeight, { toValue: windowWidth * ratio, diff --git a/src/screens/Shared/Account/Information.tsx b/src/screens/Shared/Account/Information.tsx index 5b766e6e..337c62f3 100644 --- a/src/screens/Shared/Account/Information.tsx +++ b/src/screens/Shared/Account/Information.tsx @@ -1,6 +1,6 @@ -import React, { createRef, useEffect, useState } from 'react' +import React, { createRef, useContext, useEffect, useState } from 'react' import { Animated, Image, StyleSheet, Text, View } from 'react-native' -import ShimmerPlaceholder from 'react-native-shimmer-placeholder' +import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder' import { Feather } from '@expo/vector-icons' import ParseContent from '@components/ParseContent' @@ -8,23 +8,27 @@ import { useTheme } from '@utils/styles/ThemeManager' import { StyleConstants } from '@utils/styles/constants' import { useTranslation } from 'react-i18next' import Emojis from '@components/Timelines/Timeline/Shared/Emojis' +import { LinearGradient } from 'expo-linear-gradient' +import { AccountContext } from '../Account' export interface Props { account: Mastodon.Account | undefined } const AccountInformation: React.FC = ({ account }) => { + const { accountDispatch } = useContext(AccountContext) const { t } = useTranslation('sharedAccount') const { theme } = useTheme() const [avatarLoaded, setAvatarLoaded] = useState(false) - const shimmerAvatarRef = createRef() - const shimmerNameRef = createRef() - const shimmerAccountRef = createRef() - const shimmerCreatedRef = createRef() - const shimmerStatTootRef = createRef() - const shimmerStatFolloingRef = createRef() - const shimmerStatFollowerRef = createRef() + const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient) + const shimmerAvatarRef = createRef() + const shimmerNameRef = createRef() + const shimmerAccountRef = createRef() + const shimmerCreatedRef = createRef() + const shimmerStatTootRef = createRef() + const shimmerStatFolloingRef = createRef() + const shimmerStatFollowerRef = createRef() useEffect(() => { const informationAnimated = Animated.stagger(400, [ Animated.parallel([ @@ -41,7 +45,18 @@ const AccountInformation: React.FC = ({ account }) => { }, []) return ( - + + accountDispatch({ + type: 'informationLayout', + payload: { + y: nativeEvent.layout.y, + height: nativeEvent.layout.height + } + }) + } + > {/* Moved or not: {account.moved} */} = ({ account }) => { const styles = StyleSheet.create({ information: { - marginTop: -30 - StyleConstants.Spacing.Global.PagePadding, + marginTop: -StyleConstants.Spacing.Global.PagePadding * 3, padding: StyleConstants.Spacing.Global.PagePadding }, avatar: { diff --git a/src/screens/Shared/Account/Nav.tsx b/src/screens/Shared/Account/Nav.tsx new file mode 100644 index 00000000..f2460e92 --- /dev/null +++ b/src/screens/Shared/Account/Nav.tsx @@ -0,0 +1,101 @@ +import Emojis from '@root/components/Timelines/Timeline/Shared/Emojis' +import { StyleConstants } from '@root/utils/styles/constants' +import { useTheme } from '@root/utils/styles/ThemeManager' +import React, { useContext } from 'react' +import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { AccountContext } from '../Account' + +export interface Props { + scrollY: Animated.Value + account: Mastodon.Account | undefined +} + +const AccountNav: React.FC = ({ scrollY, account }) => { + const { accountState } = useContext(AccountContext) + const { theme } = useTheme() + const headerHeight = useSafeAreaInsets().top + 44 + + const nameY = + Dimensions.get('screen').width * accountState.headerRatio + + StyleConstants.Avatar.L - + StyleConstants.Spacing.Global.PagePadding * 2 + + StyleConstants.Spacing.M - + headerHeight + + return ( + + + + {account?.emojis ? ( + + ) : ( + + {account?.display_name || account?.username} + + )} + + + + ) +} + +const styles = StyleSheet.create({ + base: { + ...StyleSheet.absoluteFillObject, + zIndex: 99 + }, + content: { + flex: 1, + alignItems: 'center', + overflow: 'hidden' + }, + display_name: { + flexDirection: 'row' + } +}) + +export default AccountNav diff --git a/src/screens/Shared/Account/SegmentedControl.tsx b/src/screens/Shared/Account/SegmentedControl.tsx new file mode 100644 index 00000000..b8af5103 --- /dev/null +++ b/src/screens/Shared/Account/SegmentedControl.tsx @@ -0,0 +1,81 @@ +import SegmentedControl from '@react-native-community/segmented-control' +import { StyleConstants } from '@root/utils/styles/constants' +import { useTheme } from '@root/utils/styles/ThemeManager' +import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' +import { Animated, StyleSheet } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { AccountContext } from '../Account' + +export interface Props { + scrollY: Animated.Value +} + +const AccountSegmentedControl: React.FC = ({ scrollY }) => { + const { accountState, accountDispatch } = useContext(AccountContext) + const { t } = useTranslation('sharedAccount') + const { mode, theme } = useTheme() + + const headerHeight = useSafeAreaInsets().top + 44 + const translateY = scrollY.interpolate({ + inputRange: [ + 0, + (accountState.informationLayout?.y || 0) + + (accountState.informationLayout?.height || 0) - + headerHeight + ], + outputRange: [ + 0, + -(accountState.informationLayout?.y || 0) - + (accountState.informationLayout?.height || 0) + + headerHeight + ], + extrapolate: 'clamp' + }) + + return ( + + + accountDispatch({ + type: 'segmentedIndex', + payload: nativeEvent.selectedSegmentIndex + }) + } + appearance={mode} + /> + + ) +} + +const styles = StyleSheet.create({ + base: { + ...StyleSheet.absoluteFillObject, + position: 'absolute', + left: 0, + right: 0, + zIndex: 99, + borderTopWidth: StyleSheet.hairlineWidth, + padding: StyleConstants.Spacing.Global.PagePadding + } +}) + +export default AccountSegmentedControl diff --git a/src/screens/Shared/Account/Toots.tsx b/src/screens/Shared/Account/Toots.tsx index 7eb082cd..7117c0a3 100644 --- a/src/screens/Shared/Account/Toots.tsx +++ b/src/screens/Shared/Account/Toots.tsx @@ -1,87 +1,60 @@ -import React, { useRef, useState } from 'react' -import { Dimensions, FlatList, View } from 'react-native' -import SegmentedControl from '@react-native-community/segmented-control' +import React, { useCallback, useContext } from 'react' +import { Dimensions, StyleSheet } from 'react-native' +import { TabView, SceneMap } from 'react-native-tab-view' import Timeline from '@components/Timelines/Timeline' -import { useTranslation } from 'react-i18next' +import { AccountContext } from '../Account' +import { StyleConstants } from '@root/utils/styles/constants' export interface Props { id: Mastodon.Account['id'] } const AccountToots: React.FC = ({ id }) => { - const { t } = useTranslation('sharedAccount') - const [segment, setSegment] = useState(0) - const [segmentManuallyTriggered, setSegmentManuallyTriggered] = useState( - false - ) - const horizontalPaging = useRef() + const { accountState, accountDispatch } = useContext(AccountContext) - const pages: ['Account_Default', 'Account_All', 'Account_Media'] = [ - 'Account_Default', - 'Account_All', - 'Account_Media' - ] + const [routes] = React.useState([ + { key: 'Account_Default' }, + { key: 'Account_All' }, + { key: 'Account_Media' } + ]) + const singleScene = useCallback( + ({ route }) => ( + + ), + [] + ) + const renderScene = SceneMap({ + Account_Default: singleScene, + Account_All: singleScene, + Account_Media: singleScene + }) return ( - <> - { - setSegmentManuallyTriggered(true) - setSegment(nativeEvent.selectedSegmentIndex) - horizontalPaging.current.scrollToIndex({ - index: nativeEvent.selectedSegmentIndex - }) - }} - style={{ width: '100%', height: 30 }} - /> - page} - renderItem={({ item, index }) => { - return ( - - - - ) - }} - ref={horizontalPaging} - bounces={false} - getItemLayout={(data, index) => ({ - length: Dimensions.get('window').width, - offset: Dimensions.get('window').width * index, - index - })} - horizontal - onMomentumScrollEnd={() => { - setSegmentManuallyTriggered(false) - }} - onScroll={({ nativeEvent }) => - !segmentManuallyTriggered && - setSegment( - nativeEvent.contentOffset.x <= Dimensions.get('window').width / 3 - ? 0 - : 1 - ) - } - pagingEnabled - showsHorizontalScrollIndicator={false} - /> - + null} + onIndexChange={index => + accountDispatch({ type: 'segmentedIndex', payload: index }) + } + initialLayout={{ width: Dimensions.get('window').width }} + lazy + swipeEnabled + /> ) } +const styles = StyleSheet.create({ + base: { + marginTop: StyleConstants.Spacing.Global.PagePadding + 33 + } +}) + export default AccountToots diff --git a/src/screens/Shared/Compose.tsx b/src/screens/Shared/Compose.tsx index a9765517..25fd5426 100644 --- a/src/screens/Shared/Compose.tsx +++ b/src/screens/Shared/Compose.tsx @@ -90,7 +90,7 @@ export type ComposeState = { } } -export type PostAction = +export type ComposeAction = | { type: 'spoiler' payload: Partial @@ -251,7 +251,10 @@ const composeExistingState = ({ } } } -const postReducer = (state: ComposeState, action: PostAction): ComposeState => { +const composeReducer = ( + state: ComposeState, + action: ComposeAction +): ComposeState => { switch (action.type) { case 'spoiler': return { ...state, spoiler: { ...state.spoiler, ...action.payload } } @@ -294,7 +297,7 @@ const postReducer = (state: ComposeState, action: PostAction): ComposeState => { type ContextType = { composeState: ComposeState - composeDispatch: Dispatch + composeDispatch: Dispatch } export const ComposeContext = createContext({} as ContextType) @@ -332,7 +335,7 @@ const Compose: React.FC = ({ route: { params }, navigation }) => { } const [composeState, composeDispatch] = useReducer( - postReducer, + composeReducer, params?.type && params?.incomingStatus ? composeExistingState({ type: params.type, diff --git a/src/screens/Shared/Compose/Attachments.tsx b/src/screens/Shared/Compose/Attachments.tsx index 6bcd940b..85def544 100644 --- a/src/screens/Shared/Compose/Attachments.tsx +++ b/src/screens/Shared/Compose/Attachments.tsx @@ -12,10 +12,11 @@ import { ComposeContext } from '@screens/Shared/Compose' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { useNavigation } from '@react-navigation/native' -import ShimmerPlaceholder from 'react-native-shimmer-placeholder' +import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder' import { ButtonRound } from '@components/Button' import addAttachments from '@screens/Shared/Compose/addAttachments' import { Feather } from '@expo/vector-icons' +import { LinearGradient } from 'expo-linear-gradient' const DEFAULT_HEIGHT = 200 @@ -97,6 +98,8 @@ const ComposeAttachments: React.FC = () => { ) const listFooter = useCallback(() => { + const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient) + return ( { key='Screen-Shared-Account' name='Screen-Shared-Account' component={ScreenSharedAccount} - options={({ navigation }: any) => ({ - headerTranslucent: true, - headerStyle: { backgroundColor: 'rgba(255, 255, 255, 0)' }, - headerCenter: () => null, - headerLeft: () => ( - navigation.goBack()} /> - ) - })} + options={({ navigation }: any) => { + return { + headerTranslucent: true, + headerStyle: { + backgroundColor: `rgba(255, 255, 255, 0)` + }, + headerCenter: () => null, + headerLeft: () => ( + navigation.goBack()} + /> + ) + } + }} />,