Tutorial – GUI Screen Manager for Unity

UPDATE: ALL THESE AND MORE CAN BE FOUND ON MY NEW SITE http://www.returnofthebrain.co.uk/

Recently I had need to build a simple GUI manager that could keep track of which screens the user had visited and in which order. So that a user can, for example, access a ‘shop’ page from different locations and easily be able to hit ‘back’ and return to the screen they came from.

For this system we’re going to make use of Unity’s GUI system and create a canvas inside of our scene, then create our individual screens as prefab objects so that we can control which screens we want to load and when.

The source code for the complete tutorial can be found here.

What you’ll need:

SCENE SETUP

Before we write any scripts or create any screens, there’s a couple of things we need to set up in our project first.

First thing we need to do is install the DOTween plugin, which can be done easily through the asset store for free. This is a great tweening plugin, and you don’t have to use this one specifically to complete the tutorial, I’m just going to use it to move screens around a bit easier.

Second thing we need to do is create a prefab that will serve as the starting point for our GUI canvas. This isn’t a complicated process but there’s a few steps:

  1. Create a new scene and save it, call it whatever you like, I’ve gone with “App”.
  2. Create a Canvas by right clicking in the Heirarchy view and selecting “UI -> Canvas”
  3. Drag the newly created “Event System” object onto the Canvas object.
  4. Drag the Canvas object into the Project view to create a prefab from it.
  5. Create a folder called ‘Resources’ and put the canvas prefab in there.

As always, it’s important that the Resources folder is named precisely, as it’s a special case with Unity, more information on which can be found in the documentation.

canvas_create

SCREEN PANELS

Next, we’re going to write a script that can be applied to a UIPanel object to give us some functionality every screen it going to need, such as being able to show or hide the screen and to inform child elements so that they can perform any setup or animation we need.

    public class ScreenPanel : MonoBehaviour
    {

        protected RectTransform _rectTransform;

        protected const string _WillShowScreenEvent = "OnWillShowScreen";
        protected const string _ShowScreenDoneEvent = "OnShowScreenDone";

        protected const string _WillHideScreenEvent = "OnWillHideScreen";
        protected const string _HideScreenDoneEvent = "OnHideScreenDone";

        // Use this for initialization
        public virtual void Awake()
        {
            _rectTransform = gameObject.GetComponent<RectTransform>();
            if (_rectTransform == null)
            {
                Debug.LogError("Error: No Rect Transform found on GameObject ' " + gameObject.name + "'");
            }

            HideScreenImmediate();
        }

        public virtual void ShowScreen(float delay = 0f, float duration = 0.5f)
        {
            gameObject.SetActive(true);
            float widthOffset = _rectTransform.rect.width;
            Vector3 startPosition = new Vector3(widthOffset, 0, 0);
            _rectTransform.localPosition = startPosition;

            // tween the panel to the desired position
            Sequence seq = DOTween.Sequence();
            seq.AppendInterval(delay);
            seq.Append(transform.DOLocalMoveX(0, duration));
            seq.AppendCallback(ShowScreenDone);

            // Inform any screen child objects that we are about to show the screen (screen NOT visible at this point!)
            BroadcastMessage(_WillShowScreenEvent, SendMessageOptions.DontRequireReceiver);
        }

        protected virtual void ShowScreenDone()
        {
            Debug.Log("ShowScreenDone: ");

            // Inform any screen child objects that the screen is now shown and visible
            BroadcastMessage(_ShowScreenDoneEvent, SendMessageOptions.DontRequireReceiver);
        }

        public virtual void ShowScreenImmediate()
        {
            // Inform any screen child objects that we are about to show the screen (screen NOT visible at this point!)
            BroadcastMessage(_WillShowScreenEvent, SendMessageOptions.DontRequireReceiver);

            _rectTransform.localPosition = Vector3.zero;
            gameObject.SetActive(true);
            ShowScreenDone();
        }

        public virtual void HideScreen(float duration = 0.5f, Action onDoneCallback = null)
        {
            if (onDoneCallback != null)
            {
                onDoneCallback(); // we're done as far as the GUIManager is concerned
            }

            float widthOffset = _rectTransform.rect.width;
            _rectTransform.localPosition = Vector3.zero;

            BroadcastMessage(_WillHideScreenEvent, SendMessageOptions.DontRequireReceiver);

            // tween the panel to the desired position
            transform.DOLocalMoveX(-widthOffset, duration).OnComplete(HideScreenDone);
        }

        public virtual void HideScreenDone()
        {

            BroadcastMessage(_HideScreenDoneEvent, SendMessageOptions.DontRequireReceiver);
            gameObject.SetActive(false);
        }

        public virtual void HideScreenImmediate()
        {
            gameObject.SetActive(false);
            HideScreenDone();
        }
    }

This class isn’t too complex. The Awake function grabs a reference to the RectTransform object that controls our panel, and then hides the panel instantly as we want to be able to control when it’s shown to the user from elsewhere, so it will be hidden by default.

One thing to note, though is that it’s been built to be quite easy to extend so that you can have a custom show/hide setup for a particular screen by simply overriding the appropriate functions.

Before we actually build the menu screens this script will be applied to, we’re going to write the GUI Manager script which will keep track of them.

GUI MANAGER

Next up is the real meat of this tutorial, the GUI Manager itself. This is quite a large class so I’m going to split it up into chunks and explain what each part does.

public class GUIManager
{

    public enum Screens
    {
        NONE,
        MAINMENU,
        SETTINGS,
        STORE,
        GAME
    }

    private static Dictionary<Screens, string> _requiredScreens = new Dictionary<Screens, string>() {
        // format: (Screens) screen type, (string) prefab location
        {Screens.MAINMENU, "screens/mainmenu_screen"},
        {Screens.SETTINGS, "screens/settings_screen"},
        {Screens.STORE, "screens/store_screen"},
        {Screens.GAME, "screens/game_screen"}
    };

The first thing in our class is an enumeration containing the name of each screen we’re going to have in our GUI for easy reference.

Next, we want to be able to map those names to the locations of the prefabs that will represent them. We do this with a static Dictionary that maps the Screens enum to a string. The paths represent a path in our Resources folder.

    private static Dictionary<Screens, ScreenPanel> _availableScreens = new Dictionary<Screens, ScreenPanel>();
    private static bool _initialised = false;

    private static Stack<Screens> _uiStack;
    private static GameObject _canvasRootObject;
    private static Screens _currentScreen = Screens.NONE;
    private static float _defaultDuration = 0.5f;

Next up some static variables.

‘_availableScreens’ will hold the screens we were actually able to load from the lists above, and maps the Screens enum to the ScreenPanel script in the object we loaded.

‘_initialised’ just tells us whether or not we’ve been through the process of loading and initialising our screens.

‘_uiStack’ will track our current and previous screens and allow us to add or remove screens so that we can press a generic ‘back’ button on any screen and know which screen we need to end up on. More information on what a Stack is and how it works can be found here or here.

‘_canvasRootObject’ is the game object that will represent the root of our gui, all screens will be parented to this.

‘_currentScreen’ is just that, it’s a reference to the screen we’re currently displaying, and ‘_defaultDuration’ is the default amount of time it will take a screen to go from not showing to showing and active.

        public static void InitialiseGUI()
        {
            if (_initialised == true)
            {
                Debug.LogError("Error: Cannot initialize GUI again, it is already setup.");
                return;
            }

            _canvasRootObject = GameObject.Instantiate(Resources.Load("Canvas") as GameObject);
            _canvasRootObject.name = "ui_root_canvas";

            if (_canvasRootObject == null)
            {
                Debug.LogError("Error: Could not find 'uicanvas' root");
                return;
            }

            foreach (KeyValuePair<Screens, string> entry in _requiredScreens)
            {
                CreateAndCatalogueScreen(entry.Key, entry.Value);
            }

_initialised = true
        }

        private static void CreateAndCatalogueScreen(Screens screenType, string prefabLocation)
        {
            GameObject screenPrefab = Resources.Load<GameObject>(prefabLocation) as GameObject;
            GameObject instantiatedScreen = GameObject.Instantiate(screenPrefab);
            instantiatedScreen.transform.SetParent(_canvasRootObject.transform, false);
            ScreenPanel screenPanel = instantiatedScreen.GetComponent<ScreenPanel>();
            _availableScreens.Add(screenType, screenPanel);
        }

The Initialise function does a couple of things to set up our GUI, first thing we do is instantiate the Canvas prefab we created earlier. The CreateAndCatalogueScreen function uses each key/value pair in the required screens dictionary that we set up to instantiate the prefab we will create for each screen. Those don’t exist yet, but we’ll create them next. It also does some error checking to see if we’re trying to instantiate more than once, and to see if we found the root canvas object. Then it sets the ‘_instantiated’ variable to true so that we will know the gui system has already been initialised.

        public static void OpenPage(Screens screen, bool clearHistory = false)
        {
            if (screen == _currentScreen)
            {
                Debug.LogWarning("Attempted to open the same screen, aborting.");
                return;
            }

            if (_uiStack == null)
            {
                _uiStack = new Stack<Screens>();
            }

            ScreenPanel panel = null;
            _availableScreens.TryGetValue(screen, out panel);

            if (panel != null)
            {
                if (_uiStack.Count > 0)
                {
                    // we may need to delay showing the new screen until the old one calls back
                    _availableScreens[_uiStack.Peek()].HideScreen(_defaultDuration, () =>
                    {
                        _availableScreens[screen].ShowScreen();

                        if (clearHistory)
                        {
                            _uiStack.Clear();
                        }

                        _uiStack.Push(screen);
                        _currentScreen = screen;
                    });
                }
                else // we don't need to wait
                {
                    _availableScreens[screen].ShowScreen();

                    if (clearHistory)
                    {
                        _uiStack.Clear();
                    }

                    _uiStack.Push(screen);
                    _currentScreen = screen;
                }
            }
            else
            {
                Debug.LogError("'" + screen.ToString() + "' is not a valid screen.");
            }
        }

The OpenScreen function contains a couple things that look scary if you’ve not encountered them before! We use the stack functions, and we use something called a lambda expression.

First thing we do is take in a screen name as defined in our Screens enumeration, and a bool value telling us whether or not to cleat the history. Sometimes you may want to show the user a new screen and easily have the ability to ‘pop’ back to that screen as the root.

Next a bit of error checking, we see if the screen we’re trying to open is the current one, and we check to see if the stack is null and initialise it if so.

Then we poll _availableScreens to see if the requested screen is actually loaded and available to show.

If we find a valid screen to show, and it’s not the first screen we intend to show (the stack size is bigger than zero) we ask the current screen to hide itself, and give it a lambda expression as a function to call to let us know when it’s safe to proceed to show the new screen.

First thing this piece of code does it call _uiStack.Peek to get the object at the top of the stack, which is the current screen, and uses that to lookup the ScreenPanel object we mapped in _availableScreens and tells it to hide. We pass in the default duration, as well as the lambda expression containing the code we want to run when it is done.

Other people will be able to explain lambda expressions (sometimes called anonymous methods or delegates) much better than I could, but in this case I’m using it as a nice clean way of providing a very simple callback function so that we can ask the screen panel to do something, and then do something else once it’s finished. If we wanted to do something more complex, it would probably be cleaner to store the requested screen as a variable and write this as a real function and pass the callback that way.

The code inside the lambda does a couple of things; it finds the screen mapped in _availableScreens that matches our requested screen and tells it to show itself, then it clears the stack history if requested, pushes the new screen to the stack and sets the _currentScreen variable.

If there isn’t a screen already on the stack, we do the same thing as above without the lamda expression as we know it’s safe to show the screen right away because it’s the first screen our users will see.

If we couldn’t find the screen we want to show, we put out an error message.

        public static bool CanGoBack()
        {
            return (_uiStack != null && _uiStack.Count > 1);
        }

        public static void GoBack()
        {
            if (!CanGoBack())
            {
                Debug.LogError(" _uiStack not initialized or empty, cannot go back.");
                return;
            }

            _availableScreens[_uiStack.Pop()].HideScreen(_defaultDuration, () =>
            {
                _availableScreens[_uiStack.Peek()].ShowScreen();
                _currentScreen = _uiStack.Peek();
            });
        }

Next up we need the ability to return to the previous screen. So we have a simple method to check whether or not we can go back, it just checks that the ui stack exists and has more than one screen on it and returns the result.

Then we have the actual GoBack function which is very similar to OpenScreen. It verifies that we can actually go back, then it polls the ui stack for the current screen and removes it with the Pop function before telling it to hide and passing in a lambda function which will show the (now top) previous screen by using the Peek function of the ui stack.

        public static void PopToRoot()
        {
            if (!CanGoBack())
            {
                Debug.LogError(" _uiStack not initialized or empty, cannot go to root.");
                return;
            }

            _availableScreens[_uiStack.Pop()].HideScreen(_defaultDuration, () =>
            {

                while (_uiStack.Count > 1)
                {
                    _uiStack.Pop();
                }

                _availableScreens[_uiStack.Peek()].ShowScreen();
                _currentScreen = _uiStack.Peek();
            });
        }

Lastly we may need the ability to instantly pop back to the screen at the bottom of our stack. For example if the player is 4 menus deep in the inventory and exits right back to the game screen, we may want to go directly back to the game screen rather than going back through each menu.

The screen you pop back to with this function is either the first screen you showed, or the most recent screen you showed using the clearHistory flag in OpenScreen.

It’s similar to GoBack except once we’ve verified that we can go back and the current screen has called back to let us know we can proceed, instead of going back by one we use a while loop to Pop all the previous screens from the stack until there is only one left, then show that one.

Almost there, we just need to build some example panels to use as prefabs so we can test this system!

BUILDING OUR MENU SCREENS

Next thing we’re going to do is build some panels to actually form our menu from. We’ll need to build a panel for each of the screens we defined above; main menu, settings, store and game.

This tutorial has already gotten very long, so I’ll be brief here seeing as this topic is covered in much greater detail over on the official Unity documentation.

But what we’ll need to do is create a new scene that we can use to edit our ui prefabs called “gui_prefabs” and put it in a sub-folder of Scenes called Dev, as this scene won’t be used by the game it’s just a convenient place for us to edit and build the screen prefabs.

mainmenu_inprog

Each of these screens will be very similar. In this example, the main menu screen I’ve added a ui text element and 3 buttons to the canvas, then arranged them using a vertical layout group. I’ve then dragged the panel (the highlighted object in the image) into the Resources/screens folder in order to save it as a prefab that the GUI Manager can load.

settingsscreen

The game screen is a little different, I’ve create a top and bottom panel inside the screen panel that will hold the elements as we will demonstrate extending the ScreenPanel class with this later.

gamescreen

We also need a small script for each of our buttons that will call the GUIManager to request a new screen when pressed, and another for “QUIT” and “BACK” buttons.

 

    [RequireComponent(typeof(Button))]
    public class Button_OpenScreen : MonoBehaviour
    {

        private Button _button;
        public GUIManager.Screens _target;

        void Awake()
        {
            _button = gameObject.GetComponent<Button>();
            _button.onClick.AddListener(OnClick);
        }

        public void OnClick()
        {
            GUIManager.OpenPage(_target);
        }
    }

This script is very simple, all it does is fetch the button script on the object it’s attached to, and on Awake assigns the OnClick function to be called when the button is pressed. In the inspector you can choose which screen this should request with a dropdown menu.

openscreenbutton

    [RequireComponent(typeof(Button))]
    public class Button_BackScreen : MonoBehaviour
    {

        private Button _button;

        void Awake()
        {
            _button = gameObject.GetComponent<Button>();
            _button.onClick.AddListener(OnClick);
        }

        public void OnClick()
        {
            GUIManager.GoBack();
        }
    }

This does exactly the same thing but requests that the GUI Manager go back a screen instead of opening a new one.

Lastly, we need to go back to our main scene (which at this point should only have a main camera in it) and attach a script to our main camera that will initialise the gui manager and start things up.

    public class App : MonoBehaviour
    {

        // Use this for initialization
        void Awake()
        {
            // Initialise the GUI Manager
            GUIManager.InitialiseGUI();

            // Show the first screen
            GUIManager.OpenPage(GUIManager.Screens.MAINMENU);
        }
    }

Here we are just initialising the GUIManager on awake and requesting the first screen we want to see, in this case it’s our main menu.

If you hit the play button now, you should have a functional menu system!

functionalmenu

You might notice here that my game screen animates differently, the panels slide in from different directions and slide out again before displaying the next screen. Let’s code that now by extending the screen panel.

EXTENDING THE SCREEN PANEL

You may remember those messages we broadcast to our screen panel objects earlier, _OnWillShowScreenEvent, etc? Here is where those will come on really handy.

We’re going to write a script that inherits from ScreenPanel that will slightly change the way the game screen is shown and allow child objects of the game screen to control how they come onto the screen themselves by receiving those messages.

public class GameScreenPanel : ScreenPanel
    {
        public override void ShowScreen(float delay = 0f, float duration = 0.5f)
        {
            // override the show screen animation with one unique to this screen
            Vector3 startPosition = Vector3.zero;
            _rectTransform.localPosition = startPosition;

            // just use the tween to set a callback for done
            Sequence seq = DOTween.Sequence();
            seq.AppendInterval(delay + duration);
            seq.AppendCallback(ShowScreenDone);

            // Inform any screen child objects that we are about to show the screen (screen NOT visible at this point!)
            gameObject.SetActive(true); // we need to enable the gameobject in order for it to recieve the event
            BroadcastMessage(_WillShowScreenEvent, SendMessageOptions.DontRequireReceiver);
            gameObject.SetActive(false); // we can safely disable it aftr
        }

        protected override void ShowScreenDone()
        {
            // show the screen now, as we wanted to delay it
            gameObject.SetActive(true);

            // Inform any screen child objects that the screen is now shown and visible
            BroadcastMessage(_ShowScreenDoneEvent, SendMessageOptions.DontRequireReceiver);
        }

        public override void HideScreen(float duration = 0.5f, Action onDoneCallback = null)
        {
            // Let our UI know how much time it has to get out of the way!
            BroadcastMessage(_WillHideScreenEvent, duration, SendMessageOptions.DontRequireReceiver);

            // Schedue our callback for the durations end
            Sequence seq = DOTween.Sequence();
            seq.AppendInterval(duration);
            seq.AppendCallback(HideScreenDone);

            // finally append the callback to let the GUIManager know it's safe to continue
            if (onDoneCallback != null)
            {
                seq.AppendCallback(() =>
                {
                    onDoneCallback();
                });
            }
        }
    }

This script should be attached to your game screen panel in place of the ScreenPanel script (not in addition to!)

You’ll notice what we’ve done here is remove the sliding animation and simply moved the screen into position immediately, but withheld showing it until ShowScreenDone is called. This allows us to set up by receiving the ‘_WillShowScreenEvent’ message, and then actually tell our child objects to begin their animations with the ‘_ShowScreenDoneEvent’ message.

One caveat here is that we have to prematurely set our gameobject to active before sending the ‘_WillShowScreenEvent’ message otherwise our child objects won’t receive it, and then set it to inactive again before continuing.

Lastly we’ve modified OnHideScreen so that it will use BroadcastMessage to inform child objects that the screen needs to be hidden, and sends the amount of time it has to do so along with it. For more information on the BroadcastMessage system consult the unity documentation. We then use a lambda expression to append a callback after that amount of time has passed to let the GUIManager know it’s safe to continue.

Next we need to write the code that will control the animation for the child objects.

    public class GameGUIExample : MonoBehaviour
    {

        public RectTransform _topPanel;
        public RectTransform _bottomPanel;

        private Vector3 _startPos_TopPanel;
        private Vector3 _startPos_BottomPanel;

        private Vector3 _destPos_TopPanel;
        private Vector3 _destPos_BottomPanel;

        void Awake()
        {
            // get all our initial positions
            _startPos_TopPanel = _topPanel.localPosition + Vector3.up * 140f;
            _startPos_BottomPanel = _bottomPanel.localPosition + Vector3.right * 320f;

            // get our destination positions from the current positions
            _destPos_TopPanel = _topPanel.localPosition;
            _destPos_BottomPanel = _bottomPanel.localPosition;
        }

        public void OnWillShowScreen()
        {
            // set our gui objects to their off-screen starting positions
            _topPanel.localPosition = _startPos_TopPanel;
            _bottomPanel.localPosition = _startPos_BottomPanel;
        }

        public void OnShowScreenDone()
        {
            Sequence seq = DOTween.Sequence();

            // move the top panel in from above
            seq.Append(_topPanel.DOLocalMove(_destPos_TopPanel, 0.5f));

            // move the bottom panel in from the right-hand side
            seq.Append(_bottomPanel.DOLocalMove(_destPos_BottomPanel, 0.2f));
        }

        public void OnWillHideScreen(float duration)
        {
            Sequence seq = DOTween.Sequence();

            // move the top panel in from above
            seq.Join(_topPanel.DOLocalMove(_startPos_TopPanel, duration));

            // move the bottom panel in from the right-hand side
            seq.Join(_bottomPanel.DOLocalMove(_startPos_BottomPanel, duration));
        }
    }

All I’ve done here is give the script a reference to the Top and Bottom panel objects we set up in the inspector. You’ll need to attach this script to the game screen prefab, and drag the top and bottom panels in.

Then we store a reference to where the panels start (their destination, seeing as we move them to start off-screen) and then store off-screen positions for them to start at.

OnWillShowScreen, OnShowScreenDone and OnWillHideScreen are the functions that will be called when we receive the messages from GameScreenPanel. The names must match exactly the strings you have in ScreenPanel.

OnWillShowScreen moves the panels to their off-screen starting positions, then OnShowScreenDone uses a DOTween sequence to move them both onto the screen, starting with the top panel first and then the bottom panel.

OnWillHideScreen does the same thing in reverse, and uses Join instead of Append so that they will animate at the same time, and uses the duration we pass in for the animation duration.

That’s pretty much it! You can create panels that will be aware of when they should show/hide themselves using this method.

I hope you found this useful and as always, feel free to leave any questions or feedback.

Advertisements
This entry was posted in Uncategorized. Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s