Skip to content

Commit

Permalink
Implement TabList animations for non-win32 platforms (#3124)
Browse files Browse the repository at this point in the history
* Implement animations on mac

* Fix animations not running initially on mac

* Fix tests failing

* Fix rebase errors

* Change files

* Fix tests failing again

* Fix transforms being applied in wrong order

* Fix indicator size not updating after initial tab layouts

* Refactoring code + address nits

* Fix build issue + bugs

* Fix undefined error in animatedindicator hook

* Update snapshot tests

* Address nits
  • Loading branch information
lawrencewin authored Oct 24, 2023
1 parent eca610b commit 73b5823
Show file tree
Hide file tree
Showing 13 changed files with 195 additions and 70 deletions.
2 changes: 1 addition & 1 deletion apps/fluent-tester/src/testPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ export const tests: TestDescription[] = [
name: 'TabList',
component: TabListTest,
testPageButton: Constants.HOMEPAGE_TABLIST_BUTTON,
platforms: ['win32', 'windows'],
platforms: ['macos', 'win32', 'windows'],
},
{
name: 'Tabs Legacy',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add TabList page to mac tester",
"packageName": "@fluentui-react-native/tablist",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Implement animations on mac",
"packageName": "@fluentui-react-native/tester",
"email": "[email protected]",
"dependentChangeType": "patch"
}
37 changes: 19 additions & 18 deletions packages/experimental/TabList/src/Tab/useTabAnimation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { Platform } from 'react-native';

import type { LayoutEvent, PressablePropsExtended } from '@fluentui-react-native/interactive-hooks';

Expand Down Expand Up @@ -36,26 +37,26 @@ export function useTabAnimation(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabKey, selectedKey, tokens.indicatorColor]);

// onLayout callbacks to help calculate positioning of the animated indicator
/**
* This checks to see if we have relevant info to calculate the layout position and dimensions of the indicator. If this check fails, we don't
* want to trigger a re-render by needlessly updating the TabList state.
*
* We also check if the info is good. Info can be bad in some weird cases:
* - Check if width > 0 because there is an on-going issue caused by ScrollViews initially laying out its childrens' width to 0 and height to be a bigger than expected value.
* - ScrollView also negatively affects the initial height values. For vertical TabLists, the initial height value will lay out incorrectly. Sometimes, the styling of the parent
* component combined with the ScrollView issues causes the initial height layout value to be completely unreasonable. Exactly which style that causes this issue isn't known;
* more investigation has to be done.
*/
const onTabLayout = React.useCallback(
(e: LayoutEvent) => {
/**
* This checks to see if we have relevant info to calculate the layout position and dimensions of the indicator. If this check fails, we don't
* want to trigger a re-render by needlessly updating the TabList state.
*
* We also check if the info is good. Info can be bad in some weird cases:
* - Check if width > 0 because there is an on-going issue caused by ScrollViews initially laying out its childrens' width to 0 and height to be a bigger than expected value.
* - ScrollView also negatively affects the initial height values. For vertical TabLists, the initial height value will lay out incorrectly. Sometimes, the styling of the parent
* component combined with the ScrollView issues causes the initial height layout value to be completely unreasonable. Exactly which style that causes this issue isn't known;
* more investigation has to be done.
*/
if (
e?.nativeEvent?.layout &&
layout &&
layout.tablist &&
layout.tablist.width > 0 &&
e.nativeEvent.layout.height <= layout.tablist.height &&
e.nativeEvent.layout.height < RENDERING_HEIGHT_LIMIT
e.nativeEvent.layout &&
// Following checks are for win32 only, will be removed after addressing scrollview layout bug
(Platform.OS !== ('win32' as any) ||
(layout?.tablist &&
layout?.tablist.width > 0 &&
e.nativeEvent.layout.height <= layout.tablist.height &&
e.nativeEvent.layout.height < RENDERING_HEIGHT_LIMIT))
) {
let width: number, height: number;
// Total Indicator inset consists of the horizontal/vertical margin of the indicator, the space taken up by the tab's focus border, and the
Expand All @@ -80,7 +81,7 @@ export function useTabAnimation(
});
}
},
[addTabLayout, tabKey, layout, tokens.borderWidth, tokens.indicatorMargin, tokens.indicatorThickness, vertical],
[addTabLayout, layout, tabKey, tokens.borderWidth, tokens.indicatorMargin, tokens.indicatorThickness, vertical],
);

return React.useMemo(() => ({ ...rootProps, onLayout: onTabLayout }), [rootProps, onTabLayout]);
Expand Down
11 changes: 10 additions & 1 deletion packages/experimental/TabList/src/TabList/TabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const TabList = compose<TabListType>({

const { disabled, defaultTabbableElement, isCircularNavigation, vertical, ...mergedProps } = mergeProps(tablist.props, final);

const { animatedIndicatorStyles, canShowAnimatedIndicator, layout, selectedKey } = tablist.state;

return (
<TabListContext.Provider
// Passes in the selected key and a hook function to update the newly selected tab and call the client's onTabsClick callback.
Expand All @@ -48,7 +50,14 @@ export const TabList = compose<TabListType>({
isCircularNavigation={isCircularNavigation}
>
<Slots.stack {...mergedProps}>{children}</Slots.stack>
{tablist.state.animatedIndicatorStyles && tablist.state.layout && <TabListAnimatedIndicator />}
{canShowAnimatedIndicator && (
<TabListAnimatedIndicator
animatedIndicatorStyles={animatedIndicatorStyles}
selectedKey={selectedKey}
tabLayout={layout.tabs}
vertical={vertical}
/>
)}
</Slots.container>
</TabListContext.Provider>
);
Expand Down
16 changes: 5 additions & 11 deletions packages/experimental/TabList/src/TabList/TabList.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import type { FocusZoneProps } from '@fluentui-react-native/focus-zone';
import type { LayoutTokens } from '@fluentui-react-native/tokens';
import type { LayoutRectangle } from '@office-iss/react-native-win32';

import type { AnimatedIndicatorStyles, AnimatedIndicatorStylesUpdate } from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';
import type {
AnimatedIndicatorStyles,
AnimatedIndicatorStylesUpdate,
TabLayoutInfo,
} from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';

export const tabListName = 'TabList';

export type TabListAppearance = 'transparent' | 'subtle';
export type TabListSize = 'small' | 'medium' | 'large';

export interface TabLayoutInfo extends LayoutRectangle {
startMargin?: number;
tabBorderWidth?: number;
}
export interface TabListLayoutInfo {
tablist: LayoutRectangle;
tabs: { [key: string]: TabLayoutInfo };
Expand Down Expand Up @@ -81,11 +80,6 @@ export interface TabListState {
*/
selectedKey: string;

/**
* Setter function for the `canShowAnimatedIndicator` variable for this state object.
*/
setCanShowAnimatedIndicator: (canShowAnimatedIndicator: boolean) => void;

/**
* Setter for the context's `invoked` flag.
* @platform win32
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export const TabListContext = React.createContext<TabListState>({
onTabSelect: nullFunction,
removeTabKey: nullFunction,
selectedKey: '',
setCanShowAnimatedIndicator: nullFunction,
setSelectedTabRef: nullFunction,
size: 'small',
tabKeys: [],
Expand Down
12 changes: 7 additions & 5 deletions packages/experimental/TabList/src/TabList/useTabList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import { memoize, mergeStyles } from '@fluentui-react-native/framework';
import type { LayoutEvent } from '@fluentui-react-native/interactive-hooks';
import { useSelectedKey } from '@fluentui-react-native/interactive-hooks';

import type { TabListInfo, TabListProps, TabLayoutInfo } from './TabList.types';
import type { AnimatedIndicatorStyles, AnimatedIndicatorStylesUpdate } from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';
import type { TabListInfo, TabListProps } from './TabList.types';
import type {
AnimatedIndicatorStyles,
AnimatedIndicatorStylesUpdate,
TabLayoutInfo,
} from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';

/**
* Re-usable hook for TabList.
Expand Down Expand Up @@ -56,7 +60,6 @@ export const useTabList = (props: TabListProps): TabListInfo => {
);

// State variables and functions for saving layout info and other styling information to style the animated indicator.
const [canShowAnimatedIndicator, setCanShowAnimatedIndicator] = React.useState<boolean>(false);
const [listLayoutMap, setListLayoutMap] = React.useState<{ [key: string]: TabLayoutInfo }>({});
const [tabListLayout, setTabListLayout] = React.useState<LayoutRectangle>();
const [userDefinedAnimatedIndicatorStyles, setUserDefinedAnimatedIndicatorStyles] = React.useState<AnimatedIndicatorStyles>({
Expand Down Expand Up @@ -110,7 +113,7 @@ export const useTabList = (props: TabListProps): TabListInfo => {
addTabLayout: addTabLayout,
animatedIndicatorStyles: userDefinedAnimatedIndicatorStyles,
appearance: appearance,
canShowAnimatedIndicator: canShowAnimatedIndicator,
canShowAnimatedIndicator: !!(userDefinedAnimatedIndicatorStyles && listLayoutMap && listLayoutMap[selectedKey ?? data.selectedKey]),
disabled: disabled,
invoked: invoked,
layout: {
Expand All @@ -121,7 +124,6 @@ export const useTabList = (props: TabListProps): TabListInfo => {
removeTabKey: removeTabKey,
selectedKey: selectedKey ?? data.selectedKey,
setSelectedTabRef: setSelectedTabRef,
setCanShowAnimatedIndicator: setCanShowAnimatedIndicator,
setInvoked: setInvoked,
size: size,
tabKeys: tabKeys,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
/** @jsxRuntime classic */
import React from 'react';
import { Animated, View } from 'react-native';

import { stagedComponent } from '@fluentui-react-native/framework';

import type { AnimatedIndicatorProps } from './TabListAnimatedIndicator.types';
import { tablistAnimatedIndicatorName } from './TabListAnimatedIndicator.types';
import { useAnimatedIndicatorStyles } from './useAnimatedIndicatorStyles';

export const TabListAnimatedIndicator = stagedComponent(() => () => null);
export const TabListAnimatedIndicator = stagedComponent<AnimatedIndicatorProps>((props) => {
const styles = useAnimatedIndicatorStyles(props);
return () => {
return (
<View style={styles.container}>
<Animated.View style={styles.indicator} />
</View>
);
};
});
TabListAnimatedIndicator.displayName = tablistAnimatedIndicatorName;

export default TabListAnimatedIndicator;
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { ViewStyle } from 'react-native';
import type { Animated, LayoutRectangle, ViewStyle } from 'react-native';

export const tablistAnimatedIndicatorName = 'TabListAnimatedIndicator';
export interface AnimatedIndicatorStyles {
container: ViewStyle;
indicator: ViewStyle;
indicator: Animated.AnimatedProps<ViewStyle>;
}
export type AnimatedIndicatorStylesUpdate = Partial<AnimatedIndicatorStyles>;

export interface TabLayoutInfo extends LayoutRectangle {
startMargin?: number;
tabBorderWidth?: number;
}

export interface AnimatedIndicatorProps {
animatedIndicatorStyles?: AnimatedIndicatorStyles;
selectedKey?: string;
tabLayout?: { [key: string]: TabLayoutInfo };
vertical?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,26 @@

import React from 'react';
import { View } from 'react-native';
import type { ViewProps, ViewStyle } from 'react-native';
import type { Animated, ViewProps, ViewStyle } from 'react-native';

import { stagedComponent, memoize } from '@fluentui-react-native/framework';

import type { AnimatedIndicatorProps } from './TabListAnimatedIndicator.types';
import { tablistAnimatedIndicatorName } from './TabListAnimatedIndicator.types';
import { useAnimatedIndicatorStyles } from './useAnimatedIndicatorStyles';

const getIndicatorProps = memoize(indicatorPropsWorker);
function indicatorPropsWorker(animationClass: string, style: ViewStyle): ViewProps {
function indicatorPropsWorker(animationClass: string, style: Animated.AnimatedProps<ViewStyle>): ViewProps {
return { animationClass, style } as ViewProps;
}

/**
* This component renders as the indicator for the selected tab. Its styles are manually calculated using
* changing layout stored in the tablist context, so it doesn't need to use the compose or compressible franework.
*/
export const TabListAnimatedIndicator = stagedComponent(() => {
const styles = useAnimatedIndicatorStyles();
export const TabListAnimatedIndicator = stagedComponent<AnimatedIndicatorProps>((props) => {
const styles = useAnimatedIndicatorStyles(props);
return () => {
if (!styles) {
return null;
}
const indicatorProps = getIndicatorProps('Ribbon_TabUnderline', styles.indicator);
return (
<View style={styles.container}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,65 @@
import React from 'react';
import { Animated } from 'react-native';
import type { ViewStyle } from 'react-native';

import type { AnimatedIndicatorStyles } from './TabListAnimatedIndicator.types';
import type { TabLayoutInfo } from '../TabList/TabList.types';
import { TabListContext } from '../TabList/TabListContext';
import type { AnimatedIndicatorProps, AnimatedIndicatorStyles } from './TabListAnimatedIndicator.types';

/**
* This hook handles logic for generating the styles for the TabList's Animated Indicator.
* This hook handles logic for generating the styles for the TabList's Animated Indicator. Child Tabs add layout update events to state
* variables here, which we use to either directly update the layout values of the animated indicator (on win32) or generate the transforms
* to move the indicator (on non-win32 platforms).
*/
export function useAnimatedIndicatorStyles(): AnimatedIndicatorStyles {
const { animatedIndicatorStyles, layout, selectedKey, setCanShowAnimatedIndicator, vertical } = React.useContext(TabListContext);
export function useAnimatedIndicatorStyles(props: AnimatedIndicatorProps): AnimatedIndicatorStyles {
const { animatedIndicatorStyles, selectedKey, tabLayout, vertical } = props;

const selectedIndicatorLayout = React.useMemo<TabLayoutInfo | null>(() => {
return selectedKey ? layout.tabs[selectedKey] : null;
}, [selectedKey, layout.tabs]);
// animated values
const indicatorTranslate = React.useRef(new Animated.Value(0)).current;
const indicatorScale = React.useRef(new Animated.Value(1)).current;

// Calculate styles using both layout information and user defined styles
const styles = React.useMemo<AnimatedIndicatorStyles | null>(() => {
// if not all layout props have been recorded for the current selected indicator, don't render the animated indicator
if (!selectedIndicatorLayout) {
return null;
// Save the initial selected layout, this shouldn't update
// eslint-disable-next-line react-hooks/exhaustive-deps
const startingIndicatorLayout = React.useMemo(() => tabLayout[selectedKey], []);

React.useEffect(() => {
const selectedIndicatorLayout = tabLayout[selectedKey];
if (startingIndicatorLayout && selectedIndicatorLayout) {
/**
* Calculate transforms. Because the scale transform's origin is at the center, we need to calculate an extra offset to add to the
* translate transform to place the indicator at the correct location on screen.
*/
let scaleValue: number, translateValue: number, translateOffset: number;
if (vertical) {
scaleValue = selectedIndicatorLayout.height / startingIndicatorLayout.height;
translateValue = selectedIndicatorLayout.y - startingIndicatorLayout.y;
translateOffset = (selectedIndicatorLayout.height - startingIndicatorLayout.height) / 2;
} else {
scaleValue = selectedIndicatorLayout.width / startingIndicatorLayout.width;
translateValue = selectedIndicatorLayout.x - startingIndicatorLayout.x;
translateOffset = (selectedIndicatorLayout.width - startingIndicatorLayout.width) / 2;
}
Animated.parallel([
Animated.timing(indicatorScale, {
toValue: scaleValue,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(indicatorTranslate, {
toValue: translateValue + translateOffset,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
const { x, y, width, height, startMargin, tabBorderWidth } = selectedIndicatorLayout;
}, [indicatorScale, indicatorTranslate, tabLayout, selectedKey, startingIndicatorLayout, vertical]);

// Calculate styles using both layout information and user defined styles
const styles = React.useMemo<AnimatedIndicatorStyles>(() => {
const { x, y, width, height, startMargin, tabBorderWidth } = startingIndicatorLayout;
const containerStyles: ViewStyle = {
position: 'absolute',
...animatedIndicatorStyles.container,
};
const indicatorStyles: ViewStyle = {
const indicatorStyles = {
borderRadius: 99,
...animatedIndicatorStyles.indicator,
width: width,
Expand All @@ -35,21 +68,24 @@ export function useAnimatedIndicatorStyles(): AnimatedIndicatorStyles {
if (vertical) {
containerStyles.start = x + tabBorderWidth + 1;
indicatorStyles.top = y + startMargin + tabBorderWidth + 1;
indicatorStyles.transform = [{ translateY: indicatorTranslate }, { scaleY: indicatorScale }];
} else {
containerStyles.bottom = height + y + 1;
indicatorStyles.start = x + startMargin + tabBorderWidth + 1;
indicatorStyles.transform = [{ translateX: indicatorTranslate }, { scaleX: indicatorScale }];
}
return {
container: containerStyles,
indicator: indicatorStyles,
};
}, [vertical, selectedIndicatorLayout, animatedIndicatorStyles]);

/**
* Until we have styles for the animated indicator, we show the Tab's "static indicator" for the selected key which is normally shown only on hover.
* The `canShowAnimatedIndicator` variable is used to decide whether to render the selected tab's static indicator as transparent or as colored in Tab.styling.tsx.
*/
React.useEffect(() => setCanShowAnimatedIndicator(styles !== null), [setCanShowAnimatedIndicator, styles]);
}, [
startingIndicatorLayout,
animatedIndicatorStyles.container,
animatedIndicatorStyles.indicator,
vertical,
indicatorScale,
indicatorTranslate,
]);

return styles;
}
Loading

0 comments on commit 73b5823

Please sign in to comment.