feat(sidebar): add interactive drag-to-open gesture

Sidebar now follows the finger during swipe-to-open gesture from anywhere
on the screen. Uses shared animation context between AppContainer and
SlidingSidebar for synchronized translateX values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-04 12:46:53 +07:00
co-authored by Claude Opus 4.5
parent 719ef75f3d
commit edb3258ae9
4 changed files with 269 additions and 176 deletions
@@ -0,0 +1,117 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
import { useWindowDimensions } from "react-native";
import {
useSharedValue,
withTiming,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { useSidebarStore } from "@/stores/sidebar-store";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
interface SidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
}
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const { isOpen } = useSidebarStore();
// Initialize based on current state
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const isGesturing = useSharedValue(false);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
};
const animateToClose = () => {
"worklet";
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
};
return (
<SidebarAnimationContext.Provider
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
}}
>
{children}
</SidebarAnimationContext.Provider>
);
}
export function useSidebarAnimation() {
const context = useContext(SidebarAnimationContext);
if (!context) {
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
}
return context;
}