在多个 UI 屏幕之间进行过渡的需求相当普遍。在本页面中,我们将探索一种使用动画和状态机来创建和管理这些过渡以便驱动和控制每个屏幕的简单方法。
大体思路是每个屏幕都有一个动画控制器以及两个状态(Open 和 Closed)和一个布尔参数 (Open)。要在屏幕之间过渡,只需关闭当前打开的屏幕并打开所需的屏幕。为了简化这一过程,我们将创建一个小型的 ScreenManager 类,稍后用于跟踪并处理所有已打开的屏幕。触发过渡的按钮只需要让 ScreenManager 打开所需的屏幕。
如果打算支持通过控制器/键盘对 UI 元素进行导航,必须注意几点。必须避免可选元素超出屏幕,因为这样会让玩家选择屏幕外的元素,为此我们可以停用所有屏幕外层级视图。我们还需要确保在显示新屏幕时将其中的某个元素设置为选中状态,否则玩家将无法导航到新屏幕。我们将在下面的 ScreenManager 类中处理所有这些问题。
让我们来看看进行屏幕过渡时所需的最常见和最小低限度的动画控制器设置。控制器需要一个布尔参数 (Open) 和两个状态(Open 和 Closed),每个状态应该有一段只有一个关键帧的动画,这样就能让状态机为我们执行过渡混合。
现在我们需要在两个状态之间创建过渡,让我们从 Open 到 Closed 的过渡开始,首先正确设置条件,当参数 Open 设置为 false 时,我们希望从 Open 变为 Closed。接着,我们创建从 Closed 到 Open 的过渡,并将条件设置为:当参数 Open 为 true 时,状态从 Closed 变为 Open。
进行以上所有设置后,唯一缺少的是我们需要在要过渡到的屏幕的动画器上将参数 Open 设置为 true,并在当前打开的屏幕的动画器上将 Open 设置为 false。为此,我们将创建一个小脚本:
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
using System.Collections.Generic;
public class ScreenManager : MonoBehaviour {
//Screen to open automatically at the start of the Scene
public Animator initiallyOpen;
//Currently Open Screen
private Animator m_Open;
//Hash of the parameter we use to control the transitions.
private int m_OpenParameterId;
//The GameObject Selected before we opened the current Screen.
//Used when closing a Screen, so we can go back to the button that opened it.
private GameObject m_PreviouslySelected;
//Animator State and Transition names we need to check against.
const string k_OpenTransitionName = "Open";
const string k_ClosedStateName = "Closed";
public void OnEnable()
{
//We cache the Hash to the "Open" Parameter, so we can feed to Animator.SetBool.
m_OpenParameterId = Animator.StringToHash (k_OpenTransitionName);
//If set, open the initial Screen now.
if (initiallyOpen == null)
return;
OpenPanel(initiallyOpen);
}
//Closes the currently open panel and opens the provided one.
//It also takes care of handling the navigation, setting the new Selected element.
public void OpenPanel (Animator anim)
{
if (m_Open == anim)
return;
//Activate the new Screen hierarchy so we can animate it.
anim.gameObject.SetActive(true);
//Save the currently selected button that was used to open this Screen. (CloseCurrent will modify it)
var newPreviouslySelected = EventSystem.current.currentSelectedGameObject;
//Move the Screen to front.
anim.transform.SetAsLastSibling();
CloseCurrent();
m_PreviouslySelected = newPreviouslySelected;
//Set the new Screen as then open one.
m_Open = anim;
//Start the open animation
m_Open.SetBool(m_OpenParameterId, true);
//Set an element in the new screen as the new Selected one.
GameObject go = FindFirstEnabledSelectable(anim.gameObject);
SetSelected(go);
}
//Finds the first Selectable element in the providade hierarchy.
static GameObject FindFirstEnabledSelectable (GameObject gameObject)
{
GameObject go = null;
var selectables = gameObject.GetComponentsInChildren<Selectable> (true);
foreach (var selectable in selectables) {
if (selectable.IsActive () && selectable.IsInteractable ()) {
go = selectable.gameObject;
break;
}
}
return go;
}
//Closes the currently open Screen
//It also takes care of navigation.
//Reverting selection to the Selectable used before opening the current screen.
public void CloseCurrent()
{
if (m_Open == null)
return;
//Start the close animation.
m_Open.SetBool(m_OpenParameterId, false);
//Reverting selection to the Selectable used before opening the current screen.
SetSelected(m_PreviouslySelected);
//Start Coroutine to disable the hierarchy when closing animation finishes.
StartCoroutine(DisablePanelDeleyed(m_Open));
//No screen open.
m_Open = null;
}
//Coroutine that will detect when the Closing animation is finished and it will deactivate the
//hierarchy.
IEnumerator DisablePanelDeleyed(Animator anim)
{
bool closedStateReached = false;
bool wantToClose = true;
while (!closedStateReached && wantToClose)
{
if (!anim.IsInTransition(0))
closedStateReached = anim.GetCurrentAnimatorStateInfo(0).IsName(k_ClosedStateName);
wantToClose = !anim.GetBool(m_OpenParameterId);
yield return new WaitForEndOfFrame();
}
if (wantToClose)
anim.gameObject.SetActive(false);
}
//Make the provided GameObject selected
//When using the mouse/touch we actually want to set it as the previously selected and
//set nothing as selected for now.
private void SetSelected(GameObject go)
{
//Select the GameObject.
EventSystem.current.SetSelectedGameObject(go);
//If we are using the keyboard right now, that's all we need to do.
var standaloneInputModule = EventSystem.current.currentInputModule as StandaloneInputModule;
if (standaloneInputModule != null && standaloneInputModule.inputMode == StandaloneInputModule.InputMode.Buttons)
return;
//Since we are using a pointer device, we don't want anything selected.
//But if the user switches to the keyboard, we want to start the navigation from the provided game object.
//So here we set the current Selected to null, so the provided gameObject becomes the Last Selected in the EventSystem.
EventSystem.current.SetSelectedGameObject(null);
}
}
让我们连接此脚本,为此需要创建新的游戏对象,我们可以将其重命名为“ScreenManager”之类的名称,并将上面的组件添加到该游戏对象。您可以为其指定初始屏幕,此屏幕将在场景启动时打开。
现在,最后的工作是让 UI 按钮生效。选择应触发屏幕过渡的按钮,并在 Inspector 的 On Click () 列表下添加一个新操作。将刚创建的 ScreenManager 游戏对象拖到 ObjectField,在下拉选单中选择 ScreenManager > OpenPanel (Animator),然后将用户点击按钮时要打开的面板拖放到 ObjectField。
这种方法只要求每个屏幕有一个动画控制器 (AnimatorController) 以及一个 Open 参数和一个 Closed 状态即可发挥作用(无论屏幕或状态机的构造如何)。这种方法也适用于嵌套的屏幕,这意味着每个嵌套级别只需要一个 ScreenManager。
我们上面设置的状态机的默认状态为 Closed,因此使用此控制器的所有屏幕在开始时均为关闭状态。ScreenManager 提供了一个 initiallyOpen 属性,因此可以指定首先显示的屏幕。