mrzachnugent / react-native-reusables

Universal shadcn/ui for React Native featuring a focused collection of components - Crafted with NativeWind v4 and accessibility in mind.
https://rnr-docs.vercel.app
MIT License
3.16k stars 129 forks source link

[ BUG ] Switch color change bug (UI bug) #209

Closed periakteon closed 1 month ago

periakteon commented 1 month ago

Describe the bug The switch button has a visual bug after a few on-offs.

To Reproduce Steps to reproduce the behavior:

  1. Prebuild the app
  2. Launch the app on an Android

Expected behavior Colors should render properly according to the on-off value.

Screenshots image image

Platform (please complete the following information):

Additional context

VIDEO (CLICK TO PLAY)

IVIDEO

The folder structure of the application is as follows:

└── app/
    ├── _layout.tsx
    ├── index.tsx
    └── (protected)/
        ├── _layout.tsx
        └── profile.tsx

(root) _layout.tsx

import "~/global.css";

import { Theme, ThemeProvider } from "@react-navigation/native";
import { Slot, SplashScreen } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { Platform } from "react-native";
import { NAV_THEME } from "~/lib/constants";
import { useColorScheme } from "~/lib/useColorScheme";
import { PortalHost } from "@rn-primitives/portal";
import { useEffect, useState } from "react";
import { useFonts } from "expo-font";
import AuthProvider from "~/providers/AuthProvider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { storage } from "~/lib/storage";

const LIGHT_THEME: Theme = {
  dark: false,
  colors: NAV_THEME.light,
};
const DARK_THEME: Theme = {
  dark: true,
  colors: NAV_THEME.dark,
};

export {
  // Catch any errors thrown by the Layout component.
  ErrorBoundary,
} from "expo-router";

export const unstable_settings = {
  initialRouteName: "/",
};

// Prevent the splash screen from auto-hiding before getting the color scheme.
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [isColorSchemeLoaded, setIsColorSchemeLoaded] = useState(false);

  const { colorScheme, setColorScheme, isDarkColorScheme } = useColorScheme();

  const [loaded, error] = useFonts({
    "Geist-Black": require("~/assets/fonts/Geist-Black.ttf"),
    "Geist-Bold": require("~/assets/fonts/Geist-Bold.ttf"),
    "Geist-Light": require("~/assets/fonts/Geist-Light.ttf"),
    "Geist-Medium": require("~/assets/fonts/Geist-Medium.ttf"),
    "Geist-Regular": require("~/assets/fonts/Geist-Regular.ttf"),
    "Geist-SemiBold": require("~/assets/fonts/Geist-SemiBold.ttf"),
    "Geist-Thin": require("~/assets/fonts/Geist-Thin.ttf"),
    "Geist-UltraBlack": require("~/assets/fonts/Geist-UltraBlack.ttf"),
    "Geist-UltraLight": require("~/assets/fonts/Geist-UltraLight.ttf"),
  });

  useEffect(() => {
    (async () => {
      const theme = storage.getString("theme");

      if (Platform.OS === "web") {
        // Adds the background color to the html element to prevent white background on overscroll.
        document.documentElement.classList.add("bg-background");
      }

      if (!theme) {
        storage.set("theme", colorScheme);

        setIsColorSchemeLoaded(true);

        return;
      }

      const colorTheme = theme === "dark" ? "dark" : "light";

      if (colorTheme !== colorScheme) {
        setColorScheme(colorTheme);

        setIsColorSchemeLoaded(true);

        return;
      }

      setIsColorSchemeLoaded(true);
    })().finally(() => {
      SplashScreen.hideAsync();
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  if (!isColorSchemeLoaded) {
    return null;
  }

  if (!loaded && !error) {
    return null;
  }

  return (
    <ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
      <AuthProvider>
        <StatusBar style={isDarkColorScheme ? "light" : "dark"} />
        <GestureHandlerRootView style={{ flex: 1 }}>
          <Slot />
          <PortalHost />
        </GestureHandlerRootView>
      </AuthProvider>
    </ThemeProvider>
  );
}

(protected) _layout.tsx:

import { Tabs } from "expo-router";
import { ThemeToggle } from "~/components/ThemeToggle";
import { User } from "~/lib/icons/User";
import { MapPin } from "~/lib/icons/MapPin";
import { useColorScheme } from "~/lib/useColorScheme";
import PlaceProvider from "~/providers/PlaceProvider";
import SettingsProvider from "~/providers/SettingsProvider";

export default function ProtectedLayout() {
  const { isDarkColorScheme } = useColorScheme();

  return (
    <SettingsProvider>
      <PlaceProvider>
        <Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
          <Tabs.Screen
            name="index"
            options={{
              title: "Anasayfa",
              headerTitle: "",
              // headerTransparent: true,
              headerRight: () => <ThemeToggle />,
              tabBarIcon: ({ focused }) => (
                <MapPin
                  color={
                    focused ? "#FF204E" : isDarkColorScheme ? "white" : "black"
                  }
                />
              ),
              tabBarActiveTintColor: "#FF204E",
              tabBarInactiveTintColor: isDarkColorScheme ? "white" : "black",
            }}
          />
          <Tabs.Screen
            name="profile"
            options={{
              title: "Profil",
              headerTitle: "",
              headerRight: () => <ThemeToggle />,
              tabBarIcon: ({ focused }) => (
                <User
                  color={
                    focused ? "#FF204E" : isDarkColorScheme ? "white" : "black"
                  }
                />
              ),
              tabBarActiveTintColor: "#FF204E",
              tabBarInactiveTintColor: isDarkColorScheme ? "white" : "black",
            }}
          />
        </Tabs>
      </PlaceProvider>
    </SettingsProvider>
  );
}

profile.tsx:

import { View } from "react-native";
import React from "react";
import { Button } from "~/components/ui/button";
import { supabase } from "~/lib/supabase";
import { useAuth } from "~/providers/AuthProvider";
import { useSettings } from "~/providers/SettingsProvider";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { useColorScheme } from "~/lib/useColorScheme";

export default function Profile() {
  const { session } = useAuth();
  const { colorScheme, setColorScheme } = useColorScheme();
  const { isRouteLineDashed, setIsRouteLineDashed } = useSettings();

  return (
    <>
      <View className="p-7">
        <Label nativeID="user-id" className="text-navy text-center">
          {JSON.stringify(session?.user.user_metadata.username)}
        </Label>
        <Button
          variant={"destructive"}
          onPress={() => {
            supabase.auth.signOut();
          }}
        >
          <Label nativeID="logout" className="text-white">
            Logout
          </Label>
        </Button>
      </View>
      <View className="flex-1 justify-center items-center p-6 gap-12">
        <View className="flex-row items-center gap-2">
          <Switch
            checked={isRouteLineDashed}
            onCheckedChange={setIsRouteLineDashed}
            nativeID="isRouteLineDashed"
          />
          <Label
            nativeID="isRouteLineDashed"
            onPress={() => {
              setIsRouteLineDashed((prev) => !prev);
            }}
          >
            Dashed route?
          </Label>
          <Switch
            checked={colorScheme === "dark" ? true : false}
            onCheckedChange={() =>
              setColorScheme(colorScheme === "dark" ? "light" : "dark")
            }
            nativeID="isDarkTheme"
          />
          <Label
            nativeID="isDarkTheme"
            onPress={() => {
              setColorScheme(colorScheme === "dark" ? "light" : "dark");
            }}
          >
            Dark theme?
          </Label>
        </View>
      </View>
    </>
  );
}

BONUS: SettingsProvider.tsx:

import {
  createContext,
  useContext,
  useState,
  PropsWithChildren,
  useEffect,
} from "react";
import { Alert } from "react-native";
import { storage } from "~/lib/storage";

type SettingsContextType = {
  isRouteLineDashed: boolean;
  setIsRouteLineDashed: React.Dispatch<React.SetStateAction<boolean>>;
};

const SettingsContext = createContext<SettingsContextType | undefined>(
  undefined
);

// Key for MMKV
const IS_ROUTE_LINE_DASHED_KEY = "isRouteLineDashed";

export default function SettingsProvider({ children }: PropsWithChildren) {
  const [isRouteLineDashed, setIsRouteLineDashed] = useState<boolean>(false);

  // Load isRouteLineDashed from MMKV
  useEffect(() => {
    const loadIsRouteLineDashed = () => {
      try {
        const storedValue = storage.getBoolean(IS_ROUTE_LINE_DASHED_KEY);
        if (storedValue) {
          setIsRouteLineDashed(storedValue);
        }
      } catch (error: unknown) {
        if (error instanceof Error)
          Alert.alert("Ayarlar yüklenirken hata oluştu", error.message);
      }
    };
    loadIsRouteLineDashed();
  }, []);

  // Save isRouteLineDashed to MMKV whenever it changes
  useEffect(() => {
    const saveIsRouteLineDashed = () => {
      try {
        storage.set(IS_ROUTE_LINE_DASHED_KEY, isRouteLineDashed);
      } catch (error: unknown) {
        if (error instanceof Error)
          Alert.alert("Ayarlar kaydedilirken hata oluştu", error.message);
      }
    };
    saveIsRouteLineDashed();
  }, [isRouteLineDashed]);

  return (
    <SettingsContext.Provider
      value={{
        isRouteLineDashed,
        setIsRouteLineDashed,
      }}
    >
      {children}
    </SettingsContext.Provider>
  );
}

export const useSettings = () => {
  const context = useContext(SettingsContext);

  if (!context) {
    throw new Error("useSettings must be used within a SettingsProvider");
  }

  return context;
};
anymore1405 commented 1 month ago

Try this:

diff --git a/packages/reusables/src/components/ui/switch.tsx b/packages/reusables/src/components/ui/switch.tsx
index b9d136c..a7eb9e6 100644
--- a/packages/reusables/src/components/ui/switch.tsx
+++ b/packages/reusables/src/components/ui/switch.tsx
@@ -4,7 +4,7 @@ import { Platform } from 'react-native';
 import Animated, {
   interpolateColor,
   useAnimatedStyle,
-  useDerivedValue,
+  useSharedValue,
   withTiming,
 } from 'react-native-reanimated';
 import { useColorScheme } from '../../lib/useColorScheme';
@@ -51,7 +51,7 @@ const SwitchNative = React.forwardRef<
   React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
 >(({ className, ...props }, ref) => {
   const { colorScheme } = useColorScheme();
-  const translateX = useDerivedValue(() => (props.checked ? 18 : 0));
+  const translateX = useSharedValue(props.checked ? 18 : 0);
   const animatedRootStyle = useAnimatedStyle(() => {
     return {
       backgroundColor: interpolateColor(
@@ -62,8 +62,13 @@ const SwitchNative = React.forwardRef<
     };
   });
   const animatedThumbStyle = useAnimatedStyle(() => ({
-    transform: [{ translateX: withTiming(translateX.value, { duration: 200 }) }],
+    transform: [{ translateX: translateX.value }],
   }));
+
+  React.useEffect(() => {
+    translateX.value = withTiming(props.checked ? 18 : 0);
+  }, [props.checked]);
+
   return (
     <Animated.View
       style={animatedRootStyle}