callstack / react-native-paper

Material Design for React Native (Android & iOS)
https://reactnativepaper.com
MIT License
12.54k stars 2.05k forks source link

Snackbar as a function. #2825

Open kartikeyvaish opened 2 years ago

kartikeyvaish commented 2 years ago

It would be great if we could use Snackbar and toggle it as a function.

Like in Toast provided by Native Base, we can customize it like this

import { Toast } from "native-base";

function show({
  text,
  type = "danger",
  buttonText,
  duration = 3000,
  position,
  bottom = 60,
}) {
  Toast.show({
    text: text,
    position: position,
    type: type,
    duration: duration,
    buttonText: buttonText,
  });
}

export default {
  show,
};

Then I use it on any screen or component like this

Toast.show({ text: "This API call was successful" }); // This pops a Toast message and disappears after 3 seconds

I love the Snackbar that React Native Paper has, but I couldn't figure out how to use it as described above.

Right now, I have to add a component on every page and maintain a state to toggle it. It would have been so good if we could use it like Snackbar.show("Some Text")

If anyone has any advice on using Snackbar as a function to toggle Toasts, I would love to discuss it.

Versions :

github-actions[bot] commented 2 years ago

Hey! Thanks for opening the issue. The issue doesn't seem to contain a link to a repro (a snack.expo.io link or link to a GitHub repo under your username).

Can you provide a minimal repro which demonstrates the issue? A repro will help us debug the issue faster. Please try to keep the repro as small as possible and make sure that we can run it without additional setup.

github-actions[bot] commented 2 years ago

Couldn't find version numbers for the following packages in the issue:

Can you update the issue to include version numbers for those packages? The version numbers must match the format 1.2.3.

github-actions[bot] commented 2 years ago

Couldn't find version numbers for the following packages in the issue:

Can you update the issue to include version numbers for those packages? The version numbers must match the format 1.2.3.

The versions mentioned in the issue for the following packages differ from the latest versions on npm:

Can you verify that the issue still exists after upgrading to the latest versions of these packages?

suleymanbariseser commented 1 year ago

I created a library for handling snackbar in an easy way. It also supports stacks if you would like to have https://www.npmjs.com/package/react-native-paper-snackbar-stack

ericmatte commented 1 year ago

Here is my solution, that also works with react-navigation:

// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Provider as PaperProvider } from 'react-native-paper';

import Snackbar from '$common/Snackbar';

import Test from './Test';

const Stack = createNativeStackNavigator();

function App() {
  return (
    <PaperProvider>
      <NavigationContainer>
        <Stack.Navigator initialRouteName="TEST">
          <Stack.Screen name="TEST" component={TEST} />
        </Stack.Navigator>
      </NavigationContainer>
      <Snackbar.Component /> // <-- Global Snackbar
    </PaperProvider>
  );
}

export default App;
// $common/Snackbar.tsx
import { useEffect, useState } from 'react';
import { Snackbar as PaperSnackbar } from 'react-native-paper';

import SnackbarManager from './SnackbarManager';

type State = {
  visible: boolean;
  title?: string;
};

const Snackbar = () => {
  const [state, setState] = useState<State>({ visible: false });

  useEffect(() => {
    SnackbarManager.setListener((title) => setState({ visible: true, title }));
    return () => SnackbarManager.setListener(null);
  }, []);

  return (
    <PaperSnackbar visible={state.visible} onDismiss={() => setState({ ...state, visible: false })} duration={6000}>
      {state.title}
    </PaperSnackbar>
  );
};

export default Snackbar;

And I have a global SnackbarManager:

// SnackbarManager.ts
type Listener = (title: string) => void;

class SnackbarManager {
  listener: Listener | null = null;

  constructor() {
    this.show = this.show.bind(this);
    this.setListener = this.setListener.bind(this);
  }

  setListener(listener: Listener | null): void {
    this.listener = listener;
  }

 show(title: string): void {
    this.listener?.(title);
  }
}

export default new SnackbarManager();

And finally this is how to trigger it:

import Snackbar from '$common/SnackbarManager';

Snackbar.show("Working!")

Hope this helps someone.

tonihm96 commented 8 months ago

I have used a different approach, in my case, I'm using a snackbar queue context:

import React, {
  ComponentPropsWithoutRef,
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
} from 'react';

import { IconName } from '@/components/Icon';

interface SnackbarAction {
  label: string;
  onPress: () => void;
}

interface SnackbarData {
  text: string;
  action?: SnackbarAction;
  icon?: IconName;
  onIconPress?: () => void;
  duration?: number;
}

type EnqueueSnackbar = (message: SnackbarData | string) => void;

export interface SnackbarController extends SnackbarData {
  onDismiss: () => void;
  visible: boolean;
}

interface SnackbarProviderProps {
  children: ReactNode;
}

interface SnackbarContextData {
  queue: SnackbarData[];
  enqueue: EnqueueSnackbar;
  dequeue: () => void;
  clearQueue: () => void;
  visible: boolean;
  controller: SnackbarController;
}

const SNACKBAR_FADE_TRANSITION_DURATION = 200;

const SnackbarContext = createContext({} as SnackbarContextData);

export const SnackbarProvider = ({ children }: SnackbarProviderProps) => {
  const [queue, setQueue] = useState<SnackbarData[]>([]);
  const [visible, setVisible] = useState(false);

  const dequeueTimer = useRef<NodeJS.Timeout>();

  const dequeue = () => {
    setVisible(false);
  };

  const enqueue: EnqueueSnackbar = message => {
    const isMessageString = typeof message === 'string';

    const newSnackbar: SnackbarData = isMessageString
      ? {
          text: message,
          onIconPress: dequeue,
        }
      : message;

    setQueue(prevSnackbars => [...prevSnackbars, newSnackbar]);
  };

  const clearQueue = () => {
    setQueue([]);
  };

  useEffect(() => {
    if (!visible) {
      dequeueTimer.current = setTimeout(() => {
        setQueue(prevSnackbars => prevSnackbars.slice(1));
      }, SNACKBAR_FADE_TRANSITION_DURATION);

      return () => {
        clearTimeout(dequeueTimer.current);
      };
    }
  }, [visible]);

  useEffect(() => {
    if (queue.length > 0) {
      setVisible(true);
    }
  }, [queue]);

  return (
    <SnackbarContext.Provider
      value={{
        queue,
        visible,
        enqueue,
        dequeue,
        clearQueue,
        controller: {
          ...queue[0],
          onDismiss: dequeue,
          visible,
        },
      }}
    >
      {children}
    </SnackbarContext.Provider>
  );
};

export const useSnackbar = () => useContext(SnackbarContext);

to make it flexible, I also created a custom snackbar component, in which I pass the controller object as props for the component:

import React from 'react';
import { Snackbar as PaperSnackbar } from 'react-native-paper';

import { useSnackbar } from '@/contexts/snackbar';
import { sizes } from '@/styles/sizes';

interface SnackbarProps {
  marginBottom?: number;
}

export const Snackbar = ({ marginBottom = sizes.md }: SnackbarProps) => {
  const {
    controller: { text, ...props },
    queue,
  } = useSnackbar();

  return (
    <PaperSnackbar {...props} style={{ marginBottom }}>
      {queue.length > 1 ? `(${queue.length}) ${text}` : text}
    </PaperSnackbar>
  );
};

Snackbar context provider is then placed within paper's provider:

import React, { ReactNode } from 'react';
import { Provider as PaperProvider } from 'react-native-paper';

import { SettingsConsumer, SettingsProvider } from '@/contexts/settings';
import { Settings } from '@/reducers/settings';

import { SnackbarProvider } from './snackbar';

export interface InitialState {
  settings: Settings;
}

interface AppProviderProps {
  children: ReactNode;
  initialState: InitialState;
}

export const AppProvider = ({ children, initialState }: AppProviderProps) => {
  const { settings } = initialState;

  return (
      <SettingsProvider initialState={settings}>
        <SettingsConsumer>
          {({ theme }) => (
            <PaperProvider theme={theme}>
              <SnackbarProvider>
                {children}
              </SnackbarProvider>
            </PaperProvider>
          )}
        </SettingsConsumer>
      </SettingsProvider>
  );
};

to use it, just place useSnackbar in your component, and call the enqueue function:

import React, { useEffect } from 'react';

//...your imports

import { useSnackbar } from '@contexts/snackbar';
import { Snackbar } from '@components/snackbar';

export const MyComponent = () => {
  const snackbar = useSnackbar();
  // use case example
  const { error } = useAxios();

  //...your code here

  useEffect(() => {
    if (error) {
      snackbar.enqueue(error.message);
      // or
      // snackbar.enqueue({ message: error.message, duration: 5000 });
    }
  }, [error]);

  return (
    <>
      {/* ...your code here */}

      <Snackbar />
    </>
  )
}
angelxmoreno commented 3 months ago

@Tonihm96 @ericmatte have ya'll considered publishing your solutions? @suleymanbariseser are you still maintaining that repo?

angelxmoreno commented 3 months ago

@kartikeyvaish please close this is you found a solution.

ericmatte commented 2 months ago

@tonihm96 @ericmatte have ya'll considered publishing your solutions?

@angelxmoreno good point. As for my solution, it only support one snackbar at the time, so it would required some rework.

Maybe the solution from @tonihm96 is better on that front.