Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-structure TabList Animated Indicator code to work better with RTL layouts #3236

Merged
merged 11 commits into from
Dec 13, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const TabListDefaultTest: React.FunctionComponent = () => {
<Line />
<Text>Selected Key: {key}</Text>
<PaddedTabList
vertical
selectedKey={key}
onTabSelect={(val) => {
console.log('New key:', val);
Expand Down Expand Up @@ -227,5 +228,5 @@ export const TabListTest: React.FunctionComponent = () => {

const description = 'With Tabs, users can navigate to another view.';

return <Test name="TabsV1 Test" description={description} sections={sections} status={status} e2eSections={e2eSections} />;
return <Test name="TabList Test" description={description} sections={sections} status={status} e2eSections={e2eSections} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Change TabList test page title to be correct",
"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": "Fix RTL styling on win32",
"packageName": "@fluentui-react-native/tester",
"email": "[email protected]",
"dependentChangeType": "patch"
}
3 changes: 2 additions & 1 deletion packages/experimental/TabList/src/Tab/Tab.styling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,14 @@ export const useTabSlotProps = (props: TabProps, tokens: TabTokens, theme: Theme
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
flex: vertical ? 0 : 1,
alignSelf: 'flex-start',
justifyContent: 'center',
marginHorizontal: tokens.stackMarginHorizontal,
marginVertical: tokens.stackMarginVertical,
},
}),
[tokens.stackMarginHorizontal, tokens.stackMarginVertical],
[vertical, tokens.stackMarginHorizontal, tokens.stackMarginVertical],
);

const indicatorContainer = React.useMemo<IViewProps>(
Expand Down
99 changes: 68 additions & 31 deletions packages/experimental/TabList/src/Tab/useTabAnimation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { Platform } from 'react-native';
import { I18nManager, Platform } from 'react-native';
import type { LayoutRectangle } from 'react-native';

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

Expand Down Expand Up @@ -29,13 +30,52 @@ export function useTabAnimation(
const { addTabLayout, selectedKey, layout, updateAnimatedIndicatorStyles, vertical } = context;
const { tabKey } = props;

const [tabLayoutRect, setTabLayoutRect] = React.useState<LayoutRectangle>();

// If we're the selected tab, we style the TabListAnimatedIndicator with the correct token value set by the user
React.useEffect(() => {
if (tabKey === selectedKey && updateAnimatedIndicatorStyles) {
updateAnimatedIndicatorStyles({ indicator: { backgroundColor: tokens.indicatorColor } });
updateAnimatedIndicatorStyles({ backgroundColor: tokens.indicatorColor, borderRadius: tokens.borderRadius });
}
// Disabling warning because effect does not need to fire on `updateAnimatedIndicatorStyles` being changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabKey, selectedKey, tokens.indicatorColor]);
}, [tabKey, selectedKey, tokens.indicatorColor, tokens.borderRadius]);

// Function to calculate indicator positioning and dimensions for the animated indicator.
const calculateAndUpdateAnimationLayoutInfo = React.useCallback(
(tabLayout: LayoutRectangle, tokens: TabTokens) => {
const { width: tabWidth, height: tabHeight, x: tabX, y: tabY } = tabLayout;
let indicatorWidth: number, indicatorHeight: number, indicatorX: number, indicatorY: 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
// existing padding between the focus border and the tab itself. Multiply by 2 to account for the start + end margin/border/padding.
const focusBorderPadding = 1;
const totalIndicatorInset = 2 * (tokens.indicatorMargin + tokens.borderWidth + focusBorderPadding);
// we can calculate the dimensions of the indicator using token values we have access to.
if (vertical) {
indicatorWidth = tokens.indicatorThickness;
indicatorHeight = tabHeight - totalIndicatorInset;
indicatorY = tabY + tokens.indicatorMargin + tokens.borderWidth + focusBorderPadding;
if (I18nManager.isRTL) {
// On RTL, the vertical tab indicator should appear to the right
indicatorX = tabX + tabWidth - (tokens.borderWidth + focusBorderPadding + indicatorWidth);
} else {
indicatorX = tabX + tokens.borderWidth + focusBorderPadding;
}
} else {
indicatorWidth = tabWidth - totalIndicatorInset;
indicatorHeight = tokens.indicatorThickness;
indicatorX = tabX + tokens.indicatorMargin + tokens.borderWidth + focusBorderPadding;
indicatorY = tabHeight + tabY - indicatorHeight - tokens.borderWidth - focusBorderPadding;
}
addTabLayout(tabKey, {
x: indicatorX,
y: indicatorY,
width: indicatorWidth,
height: indicatorHeight,
});
},
[addTabLayout, tabKey, vertical],
);

/**
* 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
Expand All @@ -49,40 +89,37 @@ export function useTabAnimation(
*/
const onTabLayout = React.useCallback(
(e: LayoutEvent) => {
if (
e.nativeEvent.layout &&
// Following checks are for win32 only, will be removed after addressing scrollview layout bug
(Platform.OS !== ('win32' as any) ||
if (e.nativeEvent.layout) {
setTabLayoutRect(e.nativeEvent.layout);
if (
// 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 &&
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
// existing padding between the focus border and the tab itself. Multiply by 2 to account for the start + end margin/border/padding.
const focusBorderPadding = 1;
const totalIndicatorInset = 2 * (tokens.indicatorMargin + tokens.borderWidth + focusBorderPadding);
// we can calculate the dimensions of the indicator using token values we have access to.
if (vertical) {
width = tokens.indicatorThickness;
height = e.nativeEvent.layout.height - totalIndicatorInset;
} else {
width = e.nativeEvent.layout.width - totalIndicatorInset;
height = tokens.indicatorThickness;
e.nativeEvent.layout.height < RENDERING_HEIGHT_LIMIT)
) {
calculateAndUpdateAnimationLayoutInfo(e.nativeEvent.layout, tokens);
}
addTabLayout(tabKey, {
x: e.nativeEvent.layout.x,
y: e.nativeEvent.layout.y,
width: width,
height: height,
tabBorderWidth: tokens.borderWidth,
startMargin: tokens.indicatorMargin,
});
}
},
[addTabLayout, layout, tabKey, tokens.borderWidth, tokens.indicatorMargin, tokens.indicatorThickness, vertical],
[calculateAndUpdateAnimationLayoutInfo, tokens, layout?.tablist],
);

React.useEffect(() => {
if (
(tabLayoutRect &&
// 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 &&
tabLayoutRect.height <= layout.tablist.height &&
tabLayoutRect.height < RENDERING_HEIGHT_LIMIT)
) {
rurikoaraki marked this conversation as resolved.
Show resolved Hide resolved
calculateAndUpdateAnimationLayoutInfo(tabLayoutRect, tokens);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
rurikoaraki marked this conversation as resolved.
Show resolved Hide resolved
}, [tabLayoutRect, tokens.indicatorThickness, tokens.borderWidth, tokens.indicatorMargin]);

return React.useMemo(() => ({ ...rootProps, onLayout: onTabLayout }), [rootProps, onTabLayout]);
}
15 changes: 13 additions & 2 deletions packages/experimental/TabList/src/TabList/TabList.styling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@ export const stylingSettings: UseStylingOptions<TabListProps, TabListSlotProps,
states: ['vertical'],
slotProps: {
stack: buildProps(
(tokens: TabListTokens, theme: Theme) => ({
(tokens: TabListTokens) => ({
style: {
display: 'flex',
flexDirection: tokens.direction,
flex: 0,
},
}),
['direction'],
),
root: buildProps(
(tokens: TabListTokens, theme: Theme) => ({
style: {
display: 'flex',
alignItems: 'flex-start',
...layoutStyles.from(tokens, theme),
},
}),
['direction', ...layoutStyles.keys],
layoutStyles.keys,
),
},
};
35 changes: 19 additions & 16 deletions packages/experimental/TabList/src/TabList/TabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const TabList = compose<TabListType>({
slots: {
container: FocusZone,
stack: View,
root: View,
},
useRender: (userProps: TabListProps, useSlots: UseSlots<TabListType>) => {
// configure props and state for tabs based on user props
Expand All @@ -43,22 +44,24 @@ export const TabList = compose<TabListType>({
// Passes in the selected key and a hook function to update the newly selected tab and call the client's onTabsClick callback.
value={tablist.state}
>
<Slots.container
disabled={disabled}
defaultTabbableElement={defaultTabbableElement}
focusZoneDirection={vertical ? 'vertical' : 'horizontal'}
isCircularNavigation={isCircularNavigation}
>
<Slots.stack {...mergedProps}>{children}</Slots.stack>
{canShowAnimatedIndicator && (
<TabListAnimatedIndicator
animatedIndicatorStyles={animatedIndicatorStyles}
selectedKey={selectedKey}
tabLayout={layout.tabs}
vertical={vertical}
/>
)}
</Slots.container>
<Slots.root {...mergedProps}>
<Slots.container
disabled={disabled}
defaultTabbableElement={defaultTabbableElement}
focusZoneDirection={vertical ? 'vertical' : 'horizontal'}
isCircularNavigation={isCircularNavigation}
>
<Slots.stack>{children}</Slots.stack>
lawrencewin marked this conversation as resolved.
Show resolved Hide resolved
{canShowAnimatedIndicator && (
<TabListAnimatedIndicator
animatedIndicatorStyles={animatedIndicatorStyles}
selectedKey={selectedKey}
tabLayout={layout.tabs}
vertical={vertical}
/>
)}
</Slots.container>
</Slots.root>
</TabListContext.Provider>
);
};
Expand Down
11 changes: 4 additions & 7 deletions packages/experimental/TabList/src/TabList/TabList.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ 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,
TabLayoutInfo,
} from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';
import type { AnimatedIndicatorStyles, TabLayoutInfo } from '../TabListAnimatedIndicator/TabListAnimatedIndicator.types';

export const tabListName = 'TabList';

Expand Down Expand Up @@ -104,7 +100,7 @@ export interface TabListState {
/**
* Directly update the animated indicator's styles with styles the user supplies for each slot.
*/
updateAnimatedIndicatorStyles?: (updates: AnimatedIndicatorStylesUpdate) => void;
updateAnimatedIndicatorStyles?: (updates: AnimatedIndicatorStyles) => void;

/**
* TabList's `vertical` prop.
Expand Down Expand Up @@ -174,7 +170,8 @@ export interface TabListInfo {
}
export interface TabListSlotProps {
container?: FocusZoneProps;
stack: React.PropsWithRef<IViewProps>;
stack: IViewProps;
root: React.PropsWithRef<IViewProps>;
}

export interface TabListType {
Expand Down
54 changes: 22 additions & 32 deletions packages/experimental/TabList/src/TabList/useTabList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import type { LayoutEvent } from '@fluentui-react-native/interactive-hooks';
import { useSelectedKey } from '@fluentui-react-native/interactive-hooks';

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

/**
* Re-usable hook for TabList.
Expand Down Expand Up @@ -62,37 +58,31 @@ export const useTabList = (props: TabListProps): TabListInfo => {
// State variables and functions for saving layout info and other styling information to style the animated indicator.
const [listLayoutMap, setListLayoutMap] = React.useState<{ [key: string]: TabLayoutInfo }>({});
const [tabListLayout, setTabListLayout] = React.useState<LayoutRectangle>();
const [userDefinedAnimatedIndicatorStyles, setUserDefinedAnimatedIndicatorStyles] = React.useState<AnimatedIndicatorStyles>({
container: {},
indicator: {},
});
const [userDefinedAnimatedIndicatorStyles, setUserDefinedAnimatedIndicatorStyles] = React.useState<AnimatedIndicatorStyles>({});

const addTabLayout = (tabKey: string, layoutInfo: TabLayoutInfo) => {
setListLayoutMap((prev) => ({ ...prev, [tabKey]: layoutInfo }));
};
const addTabLayout = React.useCallback(
(tabKey: string, layoutInfo: TabLayoutInfo) => {
setListLayoutMap((prev) => ({ ...prev, [tabKey]: layoutInfo }));
},
[setListLayoutMap],
);

const updateStyles = (update: AnimatedIndicatorStylesUpdate) => {
if (!update.container && !update.indicator) {
return;
}
setUserDefinedAnimatedIndicatorStyles((prev) => {
const newStyles: AnimatedIndicatorStyles = { ...prev };
if (update.container) {
newStyles.container = mergeStyles(prev.container, update.container);
}
if (update.indicator) {
newStyles.indicator = mergeStyles(prev.indicator, update.indicator);
}
return newStyles;
});
};
const updateStyles = React.useCallback(
(update: AnimatedIndicatorStyles) => {
setUserDefinedAnimatedIndicatorStyles((prev) => mergeStyles(prev, update));
},
[setUserDefinedAnimatedIndicatorStyles],
);

// TabList layout callback used to style the animated indicator.
const onTabListLayout = (e: LayoutEvent) => {
if (e.nativeEvent.layout) {
setTabListLayout(e.nativeEvent.layout);
}
};
const onTabListLayout = React.useCallback(
(e: LayoutEvent) => {
if (e.nativeEvent.layout) {
setTabListLayout(e.nativeEvent.layout);
}
},
[setTabListLayout],
);

return {
props: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsxRuntime classic */
import React from 'react';
import { Animated, View } from 'react-native';
import { Animated } from 'react-native';

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

Expand All @@ -11,11 +11,7 @@ import { useAnimatedIndicatorStyles } from './useAnimatedIndicatorStyles';
export const TabListAnimatedIndicator = stagedComponent<AnimatedIndicatorProps>((props) => {
const styles = useAnimatedIndicatorStyles(props);
return () => {
return (
<View style={styles.container}>
<Animated.View style={styles.indicator} />
</View>
);
return <Animated.View style={styles} />;
};
});
TabListAnimatedIndicator.displayName = tablistAnimatedIndicatorName;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { Animated, LayoutRectangle, ViewStyle } from 'react-native';

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

export interface TabLayoutInfo extends LayoutRectangle {
startMargin?: number;
Expand Down
Loading