Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mobile/src/common/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const ALLOW_FONT_SCALLING = false;

// Text follows the system text size, up to the „Îndrăzneț” caps (fontScaleCap in design/tokens):
// titles stop at 1.15×, text at 1.35×.
export const MAX_FONT_SCALE = { title: 1.15, text: 1.35 } as const;
export const MAX_FONT_SCALE = { title: 1.15, text: 1.35, tabLabel: 1.1 } as const;

export const MIME_TYPES: any = {
doc: 'application/msword',
Expand Down
68 changes: 60 additions & 8 deletions mobile/src/components/navigation/GlassTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import Animated, {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { elevation, fontFamily, fontScaleCap, glass, palette, tabBar } from '../../design/tokens';
import { duration, spring } from '../../design/motion';
import { duration, easing, spring, springTo } from '../../design/motion';
import { haptics } from '../../design/haptics';
import {
CalendarIcon,
Expand Down Expand Up @@ -66,6 +66,10 @@ const PILL = tabBar.pill + PILL_RING * 2;
const ACCESSORY_HEIGHT = tabBar.pill;
const ACCESSORY_GAP = 10;
const EXPAND_HINT = 'Arată toate tab-urile';
const LABEL_LINE = 13;
// how far a label may reach into the neighbouring slots („Evenimente” is 63pt, a slot at 320pt
// width is 56pt; its neighbours „Acasă” and „Ore” leave more than that free)
const LABEL_OVERHANG = 5;

/**
* Real glass on iOS 26 (Liquid Glass); older iOS a blur with 58% white over it; Android a dense
Expand Down Expand Up @@ -164,9 +168,10 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => {
? withTiming(activeIndex * slot, { duration: duration.reduced })
: withSpring(activeIndex * slot, spring.snappy);
if (!reducedMotion) {
// prototype @keyframes squish: 300 ms, widest (1.12 × 0.92) at 40%
squish.value = withSequence(
withTiming(1.12, { duration: 120 }),
withSpring(1, spring.snappy),
withTiming(1.12, { duration: 120, easing: easing.standard }),
withTiming(1, { duration: 180, easing: easing.standard }),
);
}
}, [activeIndex, slot, reducedMotion, lensX, squish]);
Expand Down Expand Up @@ -262,6 +267,8 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => {
<Text
maxFontSizeMultiplier={fontScaleCap.display}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={styles.accessoryLabel}
>
{accessory.label}
Expand Down Expand Up @@ -369,6 +376,25 @@ const TabButton = ({
}: TabButtonProps) => {
const Icon = ICONS[item.icon];
const color = focused ? palette.navy : palette.ink2;
const { fontScale } = useWindowDimensions();
// the label row is as tall as one line at the capped scale, so it never clips
const labelHeight = Math.ceil(
LABEL_LINE * Math.min(Math.max(fontScale, 1), fontScaleCap.tabLabel),
);

// prototype `.tab.on svg{transform:translateY(-1px) scale(1.06)}` and `.tab:active svg{scale(.86)}`:
// only the icon moves, the button and its slot never grow
const on = useSharedValue(focused ? 1 : 0);
const pressed = useSharedValue(0);
useEffect(() => {
on.value = springTo(focused ? 1 : 0, 'snappy', reducedMotion);
}, [focused, reducedMotion, on]);
const iconStyle = useAnimatedStyle(() => ({
transform: [
{ translateY: -on.value },
{ scale: (1 + 0.06 * on.value) * (1 - 0.14 * pressed.value) },
],
}));

// Collapsing, every slot slides toward the pill: the active one lands inside the lens, the
// others slide out of the clipped pill and fade. The slot never gets bigger than it was.
Expand All @@ -385,10 +411,13 @@ const TabButton = ({
};
});

// labels go out first (first 35% of the collapse) and come back last
// labels go out first (first 35% of the collapse, sliding 4pt down) and come back last
const labelStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 0.35], [1, 0], Extrapolation.CLAMP),
height: interpolate(progress.value, [0, 0.35], [13, 0], Extrapolation.CLAMP),
height: interpolate(progress.value, [0, 0.35], [labelHeight, 0], Extrapolation.CLAMP),
transform: [
{ translateY: interpolate(progress.value, [0, 0.35], [0, 4], Extrapolation.CLAMP) },
],
}));

return (
Expand All @@ -404,16 +433,32 @@ const TabButton = ({
accessibilityHint={collapsed && focused ? EXPAND_HINT : undefined}
accessibilityElementsHidden={hiddenWhenCollapsed}
importantForAccessibility={hiddenWhenCollapsed ? 'no-hide-descendants' : 'auto'}
// big Dynamic Type: a long press shows the label in the iOS Large Content Viewer
accessibilityShowsLargeContentViewer
accessibilityLargeContentTitle={item.label}
onPress={onPress}
onPressIn={() => {
if (!reducedMotion) {
pressed.value = springTo(1, 'snappy', false);
}
}}
onPressOut={() => {
pressed.value = springTo(0, 'snappy', reducedMotion);
}}
onLongPress={onLongPress}
hitSlop={4}
pressRetentionOffset={24}
style={styles.tab}
>
<Icon size={24} color={color} strokeWidth={focused ? 2.3 : 2} />
<Animated.View style={iconStyle}>
<Icon size={24} color={color} strokeWidth={focused ? 2.3 : 2} />
</Animated.View>
{/* never ellipsized: the label may use 5pt of each neighbour's slot and shrinks to fit */}
<Animated.Text
maxFontSizeMultiplier={fontScaleCap.display}
maxFontSizeMultiplier={fontScaleCap.tabLabel}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[
styles.label,
{ color, fontFamily: focused ? fontFamily.textHeavy : fontFamily.textBold },
Expand Down Expand Up @@ -446,7 +491,14 @@ const styles = StyleSheet.create({
row: { ...StyleSheet.absoluteFill, overflow: 'hidden', borderRadius: 999 },
tabSlot: { position: 'absolute', top: 0, bottom: 0 },
tab: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 3 },
label: { fontSize: 10.5, lineHeight: 13, letterSpacing: 0.1 },
label: {
fontSize: 10.5,
lineHeight: LABEL_LINE,
letterSpacing: 0.1,
alignSelf: 'stretch',
marginHorizontal: -LABEL_OVERHANG,
textAlign: 'center',
},
accessoryWrap: {
position: 'absolute',
left: -tabBar.side,
Expand Down
168 changes: 56 additions & 112 deletions mobile/src/components/ui/LargeTitleHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,123 +1,80 @@
// „Îndrăzneț” large title (prototype `.lt` + `.topbar`).
//
// Two pieces, because a list header scrolls but the compact bar must not:
// - the large title (Bricolage 44/0.96, 12pt under the status bar, 16 below) lives in the
// content and, with `scrollY`, shrinks to 88% and fades between 10 and 80pt of scroll (`useLargeTitleCollapse`);
// - the compact glass bar (`CompactTitleBar`, alias `LargeTitleCompactBar`) is pinned at the top,
// outside the scroll view, and comes in after 48pt.
//
// Usage:
// - ScrollView screen: <Animated.ScrollView onScroll={useTabBarScroll(scrollY)}>
// <LargeTitleHeader title="Ore" scrollY={scrollY} bar="none" /> …
// </Animated.ScrollView>
// <CompactTitleBar title="Ore" scrollY={scrollY} />
// - FlatList header: same, with the LargeTitleHeader in ListHeaderComponent and the
// CompactTitleBar after the list.
// - Static header (the title does not scroll, e.g. above a list): <LargeTitleHeader title="…"
// scrollY={scrollY} /> renders both (bar="inline", the default).
// - Compact bar only: <LargeTitleHeader title="…" scrollY={scrollY} bar="only" />.
// `compact={false}` / `compactOnly` are the older names of bar="none" / bar="only".
import React, { ReactNode } from 'react';
import { StyleSheet, View } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
interpolate,
SharedValue,
useAnimatedStyle,
Extrapolation,
} from 'react-native-reanimated';
import { View } from 'react-native';
import Animated, { SharedValue } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { VText } from './VText';
import { colors, spacing } from '../../common/theme/tokens';
import { glass, lightColors } from '../../design/tokens';
import { StyleSheet } from 'react-native-unistyles';
import { AppText } from '../../design/components/Text';
import { CompactTitleBar, useLargeTitleCollapse } from './ScreenChrome';

export { CompactTitleBar, useLargeTitleCollapse, COMPACT_BAR_AFTER } from './ScreenChrome';
/** The compact glass bar alone (same as CompactTitleBar). */
export const LargeTitleCompactBar = CompactTitleBar;

export interface LargeTitleHeaderProps {
title: string;
/** Scroll offset of the screen; when set, a compact title fades in once the large one scrolls away. */
/** Scroll offset of the screen: drives the title collapse and the compact bar. */
scrollY?: SharedValue<number>;
right?: ReactNode;
left?: ReactNode;
/**
* Render the compact glass bar here (default true). Inside a FlatList header the bar would scroll
* away with the content: pass false and render `LargeTitleCompactBar` beside the list instead.
* Where the compact glass bar goes: `inline` renders it here (only right when this header does
* not scroll), `none` leaves it to a `CompactTitleBar` you render after the list, `only` renders
* just the bar. Default `inline`.
*/
bar?: 'inline' | 'none' | 'only';
/** @deprecated use bar="none" */
compact?: boolean;
/**
* Only the compact bar, for screens whose large title scrolls inside a list (the list header
* draws its own large title).
*/
/** @deprecated use bar="only" */
compactOnly?: boolean;
/** add the safe-area top inset above the title (default true; false inside a padded list) */
safeTop?: boolean;
testID?: string;
}

// the compact glass bar appears after 48pt of scroll (handbook: Antet)
const COLLAPSE_AT = 48;

/** The compact glass bar alone, pinned to the top of the screen (fades in after 48pt of scroll). */
export const LargeTitleCompactBar = ({
title,
scrollY,
}: {
title: string;
scrollY: SharedValue<number>;
}) => {
const insets = useSafeAreaInsets();
const compactStyle = useAnimatedStyle(() => {
const y = scrollY.value;
return {
opacity: interpolate(y, [COLLAPSE_AT - 16, COLLAPSE_AT], [0, 1], Extrapolation.CLAMP),
};
});
const compactTitleStyle = useAnimatedStyle(() => {
const y = scrollY.value;
return {
transform: [
{
translateY: interpolate(
y,
[COLLAPSE_AT - 16, COLLAPSE_AT + 8],
[6, 0],
Extrapolation.CLAMP,
),
},
],
};
});
return (
<Animated.View
pointerEvents="none"
style={[styles.compact, { paddingTop: insets.top, height: insets.top + 44 }, compactStyle]}
>
<BlurView intensity={glass.blurIntensity} tint="light" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, styles.compactTint]} />
<Animated.View style={compactTitleStyle}>
<VText variant="h2" style={styles.compactTitle} numberOfLines={1}>
{title}
</VText>
</Animated.View>
</Animated.View>
);
};

/**
* „Îndrăzneț” large title (Bricolage 44) in the content. With `scrollY` it also renders a compact
* glass bar (paper at 72% over a blur, Bricolage 17 title, hairline) that fades in after 48pt of
* scroll while the large title shrinks and fades away; the compact title rises 6pt into place.
*/
export const LargeTitleHeader = ({
title,
scrollY,
right,
left,
bar,
compact = true,
compactOnly,
safeTop = true,
testID,
}: LargeTitleHeaderProps) => {
const insets = useSafeAreaInsets();

const largeStyle = useAnimatedStyle(() => {
const y = scrollY ? scrollY.value : 0;
return {
opacity: interpolate(y, [0, COLLAPSE_AT], [1, 0], Extrapolation.CLAMP),
transform: [
{
scale: interpolate(y, [-80, 0, COLLAPSE_AT], [1.08, 1, 0.92], Extrapolation.CLAMP),
},
],
};
});
const mode = bar ?? (compactOnly ? 'only' : compact ? 'inline' : 'none');
const collapse = useLargeTitleCollapse(scrollY);

return (
<>
{scrollY && (compact || compactOnly) && (
<LargeTitleCompactBar title={title} scrollY={scrollY} />
)}
{!compactOnly && (
<View style={[styles.large, { paddingTop: insets.top + spacing.m }]}>
{scrollY && mode !== 'none' && <CompactTitleBar title={title} scrollY={scrollY} />}
{mode !== 'only' && (
<View style={[styles.large, safeTop && { paddingTop: insets.top + 12 }]} testID={testID}>
{left}
<Animated.View style={[styles.titleWrap, largeStyle]}>
<VText variant="largeTitle" accessibilityRole="header" numberOfLines={1}>
<Animated.View style={[styles.titleWrap, collapse]}>
<AppText variant="largeTitle" accessibilityRole="header" numberOfLines={2}>
{title}
</VText>
</AppText>
</Animated.View>
{right}
</View>
Expand All @@ -128,27 +85,14 @@ export const LargeTitleHeader = ({

export default LargeTitleHeader;

const styles = StyleSheet.create({
compact: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
overflow: 'hidden',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.line,
alignItems: 'center',
justifyContent: 'center',
},
compactTint: { backgroundColor: lightColors.topBarTint },
compactTitle: { fontSize: 17, lineHeight: 22, letterSpacing: -0.2 },
const styles = StyleSheet.create((theme) => ({
large: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.gutter,
paddingBottom: spacing.m,
gap: spacing.m,
paddingHorizontal: theme.space.gutter,
paddingTop: 8,
paddingBottom: 16,
gap: theme.space.m,
},
titleWrap: { flex: 1, transformOrigin: 'left bottom' },
});
titleWrap: { flex: 1, minWidth: 0, transformOrigin: 'left bottom' },
}));
19 changes: 12 additions & 7 deletions mobile/src/components/ui/PressableScale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,27 @@ import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { motion } from '../../common/theme/tokens';
import { springTo } from '../../design/motion';

/** Prototype `.press:active{transform:scale(.965)}` with the snappy spring. */
export const PRESSED_SCALE = 0.965;

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

export interface PressableScaleProps extends Omit<PressableProps, 'style'> {
style?: StyleProp<ViewStyle>;
/** Scale while pressed. Defaults to the canvas value (.98). */
/** Scale while pressed. Defaults to the prototype value (.965). */
pressedScale?: number;
}

/** Pressable that shrinks slightly while pressed (canvas: `.btn:active{transform:scale(.98)}`). */
/**
* Pressable that shrinks slightly while pressed and springs back (snappy spring). Reduce Motion: no
* scale.
*/
export const PressableScale = ({
style,
pressedScale = motion.press.scale,
pressedScale = PRESSED_SCALE,
onPressIn,
onPressOut,
disabled,
Expand All @@ -37,12 +42,12 @@ export const PressableScale = ({
{...props}
onPressIn={(event) => {
if (!reducedMotion) {
scale.value = withTiming(pressedScale, { duration: motion.press.duration });
scale.value = springTo(pressedScale, 'snappy', false);
}
onPressIn?.(event);
}}
onPressOut={(event) => {
scale.value = withTiming(1, { duration: motion.press.duration });
scale.value = springTo(1, 'snappy', reducedMotion);
onPressOut?.(event);
}}
style={[style, animatedStyle]}
Expand Down
Loading
Loading