Gekidou category collapse animated (#6049)

* Adds chevron animation for collapsing categories

* Adds category body channel item collapsing

* Updates category body channel list tests and timing
This commit is contained in:
Shaz MJ
2022-03-11 23:07:34 +11:00
committed by GitHub
parent 9f61c2778c
commit 7001e29567
10 changed files with 356 additions and 180 deletions

View File

@@ -84,3 +84,28 @@ export const storeCategories = async (serverUrl: string, categories: CategoryWit
return {models: flattenedModels};
};
export const toggleCollapseCategory = async (serverUrl: string, categoryId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl].database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const category = await queryCategoryById(database, categoryId);
if (category) {
await database.write(async () => {
await category.update(() => {
category.collapsed = !category.collapsed;
});
});
}
return {category};
} catch (error) {
// eslint-disable-next-line no-console
console.log('FAILED TO COLLAPSE CATEGORY', categoryId, error);
return {error};
}
};

View File

@@ -5,95 +5,114 @@ Object {
"children": Array [
<View>
<View
onLayout={[Function]}
style={null}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
<View
animatedStyle={
Object {
"value": Object {
"height": 40,
"opacity": 1,
}
},
}
}
collapsable={false}
style={
Object {
"height": 40,
"opacity": 1,
}
}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
]
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
<View
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
"alignItems": "center",
"justifyContent": "center",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 1,
"height": 24,
"width": 24,
},
undefined,
]
}
testID="undefined.public"
/>
>
<Icon
name="globe"
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 1,
},
]
}
testID="undefined.public"
/>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
]
}
>
Channel
</Text>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
]
}
>
Channel
</Text>
</View>
</View>
</RNGestureHandlerButton>
</RNGestureHandlerButton>
</View>
</View>
</View>,
],
@@ -101,7 +120,6 @@ Object {
"data": Anything,
"getItem": [Function],
"getItemCount": [Function],
"getItemLayout": [Function],
"initialNumToRender": 20,
"invertStickyHeaders": undefined,
"keyExtractor": [Function],

View File

@@ -6,22 +6,23 @@ import {FlatList} from 'react-native';
import ChannelListItem from './channel';
import type CategoryModel from '@typings/database/models/servers/category';
type Props = {
currentChannelId: string;
sortedIds: string[];
category: CategoryModel;
};
const extractKey = (item: any) => item;
const itemLayout = (d: any, index: number) => (
{length: 40, offset: 40 * index, index}
);
const CategoryBody = ({currentChannelId, sortedIds}: Props) => {
const CategoryBody = ({currentChannelId, sortedIds, category}: Props) => {
const ChannelItem = useCallback(({item}: {item: string}) => {
return (
<ChannelListItem
channelId={item}
isActive={item === currentChannelId}
collapsed={category.collapsed}
/>
);
}, [currentChannelId]);
@@ -35,7 +36,6 @@ const CategoryBody = ({currentChannelId, sortedIds}: Props) => {
initialNumToRender={20}
windowSize={15}
updateCellsBatchingPeriod={10}
getItemLayout={itemLayout}
/>
);
};

View File

@@ -1,47 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list/categories/body/channel/item should match snapshot 1`] = `
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
<View
animatedStyle={
Object {
"value": Object {
"height": 40,
"opacity": 1,
}
},
}
}
collapsable={false}
style={
Object {
"height": 40,
"opacity": 1,
}
}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
]
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>
<View
@@ -49,66 +51,82 @@ exports[`components/channel_list/categories/body/channel/item should match snaps
Array [
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 4,
"justifyContent": "center",
},
undefined,
undefined,
Object {
"height": 24,
"width": 24,
},
undefined,
]
}
>
<Text
<View
style={
Array [
Object {
"color": "#ffffff",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 4,
"justifyContent": "center",
},
undefined,
undefined,
Object {
"fontSize": 12,
"height": 24,
"width": 24,
},
]
}
testID="undefined.gm_member_count"
>
1
</Text>
<Text
style={
Array [
Object {
"color": "#ffffff",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
},
undefined,
undefined,
Object {
"fontSize": 12,
},
]
}
testID="undefined.gm_member_count"
>
1
</Text>
</View>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
]
}
>
Hello!
</Text>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
]
}
>
Hello!
</Text>
</View>
</View>
</RNGestureHandlerButton>
</RNGestureHandlerButton>
</View>
`;

View File

@@ -34,6 +34,7 @@ describe('components/channel_list/categories/body/channel/item', () => {
isActive={false}
isOwnDirectMessage={false}
myChannel={myChannel}
collapsed={false}
/>,
);

View File

@@ -1,10 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import React, {useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {switchToChannelById} from '@actions/remote/channel';
import ChannelIcon from '@components/channel_icon';
@@ -50,9 +51,10 @@ type Props = {
isActive: boolean;
isOwnDirectMessage: boolean;
myChannel: MyChannelModel;
collapsed: boolean;
}
const ChannelListItem = ({channel, isActive, isOwnDirectMessage, myChannel}: Props) => {
const ChannelListItem = ({channel, isActive, isOwnDirectMessage, myChannel, collapsed}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
@@ -61,6 +63,19 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, myChannel}: Pro
// Make it brighter if it's highlighted, or has unreads
const bright = myChannel.isUnread || myChannel.mentionsCount > 0;
const sharedValue = useSharedValue(collapsed && !bright);
useEffect(() => {
sharedValue.value = collapsed && !bright;
}, [collapsed, bright]);
const animatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(sharedValue.value ? 0 : 40, {duration: 500}),
opacity: withTiming(sharedValue.value ? 0 : 1, {duration: 500, easing: Easing.inOut(Easing.exp)}),
};
});
const switchChannels = () => switchToChannelById(serverUrl, myChannel.id);
const membersCount = useMemo(() => {
if (channel.type === General.GM_CHANNEL) {
@@ -86,27 +101,29 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, myChannel}: Pro
}
return (
<TouchableOpacity onPress={switchChannels}>
<View style={styles.container}>
<ChannelIcon
isActive={isActive}
isArchived={channel.deleteAt > 0}
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
type={channel.type}
/>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
>
{displayName}
</Text>
<Animated.View style={animatedStyle}>
<TouchableOpacity onPress={switchChannels}>
<View style={styles.container}>
<ChannelIcon
isActive={isActive}
isArchived={channel.deleteAt > 0}
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
type={channel.type}
/>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
>
{displayName}
</Text>
</View>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Animated.View>
);
};

View File

@@ -82,12 +82,14 @@ const getSortedIds = (database: Database, category: CategoryModel, locale: strin
};
const enhance = withObservables(['category'], ({category, locale, database}: {category: CategoryModel; locale: string} & WithDatabaseArgs) => {
const sortedIds = category.observe().pipe(
const observedCategory = category.observe();
const sortedIds = observedCategory.pipe(
switchMap((c) => getSortedIds(database, c, locale)),
);
return {
sortedIds,
category: observedCategory,
};
});

View File

@@ -2,26 +2,76 @@
exports[`components/channel_list/categories/header should match snapshot 1`] = `
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"marginTop": 12,
"paddingLeft": 2,
"paddingVertical": 8,
"opacity": 1,
}
}
>
<Text
<View
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
"alignItems": "flex-start",
"flexDirection": "row",
"marginTop": 12,
"paddingLeft": 2,
"paddingVertical": 8,
}
}
>
TEST CATEGORY
</Text>
<Icon
animatedStyle={
Object {
"value": Object {
"transform": Array [
Object {
"rotate": "0deg",
},
],
},
}
}
collapsable={false}
name="chevron-down"
size={20}
style={
Object {
"color": "rgba(255,255,255,0.64)",
"height": 20,
"marginRight": 2,
"marginTop": -2,
"transform": Array [
Object {
"rotate": "0deg",
},
],
"width": 20,
}
}
/>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
}
}
>
TEST CATEGORY
</Text>
</View>
</View>
`;

View File

@@ -1,9 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import React, {useCallback, useEffect} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated, {Easing, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
import {toggleCollapseCategory} from '@actions/local/category';
import CompassIcon from '@app/components/compass_icon';
import {useServerUrl} from '@app/context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -15,11 +19,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
paddingVertical: 8,
marginTop: 12,
paddingLeft: 2,
flexDirection: 'row',
alignItems: 'flex-start',
},
heading: {
color: changeOpacity(theme.sidebarText, 0.64),
...typography('Heading', 75),
},
chevron: {
marginTop: -2,
marginRight: 2,
color: changeOpacity(theme.sidebarText, 0.64),
width: 20,
height: 20,
},
}));
type Props = {
@@ -27,9 +40,33 @@ type Props = {
hasChannels: boolean;
}
const AnimatedCompassIcon = Animated.createAnimatedComponent(CompassIcon);
const CategoryHeader = ({category, hasChannels}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const collapsed = useSharedValue(category.collapsed);
// Action
const toggleCollapse = useCallback(() => toggleCollapseCategory(serverUrl, category.id), [category.id, serverUrl]);
const rotate = useDerivedValue(() => {
return withTiming(collapsed.value ? -90 : 0, {
duration: 100,
easing: Easing.linear,
});
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{rotate: `${rotate.value}deg`}],
};
});
useEffect(() => {
collapsed.value = category.collapsed;
}, [category.collapsed]);
// Hide favs if empty
if (!hasChannels && category.type === 'favorites') {
@@ -37,11 +74,18 @@ const CategoryHeader = ({category, hasChannels}: Props) => {
}
return (
<View style={styles.container}>
<Text style={styles.heading}>
{category.displayName.toUpperCase()}
</Text>
</View>
<TouchableOpacity onPress={toggleCollapse}>
<View style={styles.container}>
<AnimatedCompassIcon
name={'chevron-down'}
style={[styles.chevron, animatedStyle]}
size={20}
/>
<Text style={styles.heading}>
{category.displayName.toUpperCase()}
</Text>
</View>
</TouchableOpacity>
);
};