這個 Scrolling List 有幾個特色:
- 使用固定數量的選單物件來代表無限數量的內容
- 弧形移動效果
- 慣性移動效果
- 滑鼠動作
- 慣性滑動
- 多個物件同時移動
- 邊界處理及環狀功能
- 弧形移動
滑鼠動作
這個實作是以 y 軸的方向為主,每個選單物件的移動以滑鼠的 y 軸變化量為依據。所以一開始先讓選單物件有取得滑鼠位移的功能。
using UnityEngine; public class ListBox : MonoBehaviour { private Vector3 lastInputWorldPos; // 上一個 frame 的滑鼠位置(world space) private Vector3 currentInputWorldPos; // 現在這個 frame 的滑鼠位置(world space) private Vector3 deltaInputWorldPos; // 位移量(world space) void Update() { // Click start. Initialize the last world position // of the mouse position. if ( Input.GetMouseButtonDown(0) ) { lastInputWorldPos = Camera.main.ScreenToWorldPoint( Input.mousePosition ); } // Get the delta value of the mouse position and // move the ListBox with the same value. else if ( Input.GetMouseButton(0) ) { currentInputWorldPos = Camera.main.ScreenToWorldPoint( Input.mousePosition ); deltaInputWorldPos = new Vector3( 0.0f, currentInputWorldPos.y - lastInputWorldPos.y, 0.0f ); updatePosition( deltaInputWorldPos ); lastInputWorldPos = currentInputWorldPos; } } void updatePosition( Vector3 deltaPosition ) { transform.position += deltaPosition; } }
Vector3 ScreenToWorldPoint( Vector3 position ) ;
想法
開始按下左鍵
如圖,在滑鼠開始按下去時,要等到下一個 frame 才會有正確的位移值,利用 Input.GetMouseButtonDown(0) ,當滑鼠左鍵被開始按下時會回傳 true,藉此更新 lastInputWorldPos。如果沒有這一步,程式會使用上一次按住滑鼠左鍵的最後位置,來計算滑鼠按住時的位移量,得到的結果會是錯誤的。
持續按住左鍵
Input.GetMouseButton(0) 在滑鼠左鍵按住時會回傳 true ( GetMouseButtonDown() 只有在滑鼠左鍵按下的瞬間才會回傳 true ),用來持續取得左鍵按下時的滑鼠位置,並計算滑鼠的 y 軸位移量,計算完畢後將 lastInputWorldPos 更新為 currentInputWorldPos。移動物件
最後將得到的位移量直接加到選單物件的 position 上,就可以達到依據滑鼠左鍵按下時的 y 軸位移量來移動物件的功能。
注意:在條件判斷中,GetMouseButtonDown(0) 一定要在 GetMouseButton(0) 前,是因為 GetMouseButton(0) 只要在左鍵按下時就會回傳 true,如果 GetMouseButtonDown(0) 放在其後,就永遠不會被判斷到。
慣性滑動
加入慣性滑動的效果就不會讓選單的移動上看起來死板板的,在視覺上有很好的感覺。
先加入兩個 global 變數來控制慣性滑動:
private bool keepSliding = false; // 要不要執行慣性滑動? private int slidingFrames; // 慣性滑動的 frame 數再來在 Update() 中增加這個功能
void Update() { ..... else if ( Input.GetMouseButton(0) ) { ...... deltaInputWorldPos = new Vector3( 0.0f, currentInputWorldPos.y - lastInputWorldPos.y, 0.0f ); updatePosition( deltaInputWorldPos ); keepSliding = true; slidingFrames = 20; lastInputWorldPos = currentInputWorldPos; } // Slide effect else if ( keepSliding ) { --slidingFrames; if ( slidingFrames == 0 ) { keepSliding = false; return; } updatePosition( deltaInputWorldPos ); deltaInputWorldPos = deltaInputWorldPos / 1.2f; } }在滑鼠左鍵按下時,判斷式只會判斷到 Input.GetMouseButton(0),當滑鼠左鍵放開時,由於 keepSliding 被設為 true,所以會執行 keepSliding 判斷式內的內容。
慣性滑動的量依據上一次按住滑鼠左鍵的最後一次位移量,每個 frame 這個值都會被除以 1.2,所以每經過一個 frame,位移量就會減少,就可以達到慣性位移的效果。至於慣性位移要執行幾個 frame 以及每個 frame 要減少多少位移量(用減法也可以),都可以依照自己的喜好。
多個物件同時移動
一開始必須決定每個物件的起始位置,個人採用以螢幕高度 ( height ) 為基礎,可以避免因為平台視窗大小不一,而造成的一些問題。需要新增幾個 global 變數:
public int listBoxID; // 這個 ListBox 的編號 public int numOfListBox; // 全部的 ListBox 數量,奇數為佳 private Vector2 maxWorldPos; // World 座標系的最大座標值 private Vector2 unitWorldPosY; // 將 World 座標分成數份,每一份的 Y 值在 Start() 函式中初始化 maxWorldPos 以及 unitWorldPosY,並計算出每一個物件的初始位置。
void Start() { ...... // Get the maximum of the world position. maxWorldPos = (Vector2) Camera.main.ScreenToWorldPoint( new Vector2( Camera.main.pixelWidth, Camera.main.pixelHeight) ); // Equally divide the range Y unitWorldPosY = new Vector2( 0.0f, maxWorldPos.y / 3.0f ); // Set the initial position accroding to ListBoxID updatePosition( new Vector3( 0.0f, unitWorldPosY.y * (float)( listBoxID * -1 + (int)numOfListBox / 2 ) ) ); }
Screen 座標系 與 World 座標系
如圖,藍色的 Screen 座標系以 Camera 的左下角為 ( 0,0 ),右上角為最大值。透過 pixelWidth 及 pixelHeight 取得 Camera 座標的最大值。注意,Camera 的位置必須是設定為 ( 0, 0 ) 才會有正確的結果。如此一來,紅色的 World 座標系原點會在 Camera 的正中央。
再利用 ScreenToWorldPoint() 函式來取得 MaxWorldPos,至於 MinWorldPos,因為在 Camera view 中 world 座標系是對稱的,所以 MinWorldPos = -MaxWorldPos。
分配物件的位置
由於要去除平台螢幕大小不同的問題,所以以 MaxWorldPos 的 y 值為基礎等分,並存入 UnitWorldPos 裡。在這裡是將 0 到 MaxWorldPos.y 做三等分,不過可以依照選單物件的大小來調整這個值,例如:3.2或2.5。
選單物件的 pivot point 皆在物件的正中央,以圖為例:每個選單物件皆有編號 ( listBoxID ),而且有 7 個 ( numOfListBox ) 選單物件,所以 ID 0 的 y 位置為 +3 個 UnitWorldPosY.y,ID 1 的 y 位置為 +2 個 UnitWorldPosY.y,而 ID 3 在正中央,至於 ID 6 則在 -3 個 UnitWorldPosY.y。依照此規律,可以得出單位位置對應 listBoxID 為:listBoxID * -1 +(int) numOfListBox / 2。(註:利用 int 除 int 會得到結果的整數部份的特性)
計算出對應的位置後,在 Start() 中呼叫 initialPosition() 來設置初始位置。
因為總有一個選單物件需要置中來標記使用者想要選取的項目,所以建議選單物件為奇數個。
/* Initialize the position of the list box accroding to its ID. */ void initialPosition( int listBoxID ) { transform.position = new Vector3( 0.0f, unitWorldPosY * (float)( listBoxID * -1 + numOfListBox / 2 ), 0.0f ); }
邊界處理及環狀功能
再來就是這個 Scrolling list 的核心功能,每個物件到達邊界時,要會從另一端出現,也就是說當物件超出自定的上界時,要從下方出現,反之亦然。
環狀功能
以 7 個選單物件為例,由左到右,模擬選單物件上移時的情況,將上界定為 +4 UnitWorldPosY.y (如圖左),當 ListBox0 的 pivot point 超過上界時 (如圖中) ,應該要出現再 -3 UnitWorldPosY.y 的位置 (如圖右)。比較圖左及圖右,可以發現選單物件的順序做了循環。同理,物件下移時,必須將下界定在 -4 UnitWorldPosY.y 的位置。
邊界處理(重要!)
由於程式是在每個 frame,依照滑鼠左鍵按住的位移量直接更新物件位置,所以當選單物件到達邊界時,其 pivot point 並不一定會剛好在邊界上,大多時候都是超出邊界。
所以當選單物件超出邊界時,必須去計算它超出了多少距離,並在另一端補上同樣的距離。右圖的貪食蛇幫助理解概念,當蛇頭超出上界兩單位時,就在另一端補上兩單位。
由於上下界的值會被頻繁使用到,所以選擇使用變數來儲存,亦可使用 static 變數,避免每次都要再計算一次。
private float lowerBoundWorldPosY; // 下界位置 private float upperBoundWorldPosY; // 上界位置 private float rangeBoundWorldPosY; // 從邊界到另一端出現點的距離在 Start() 中初始化:
void Start() { ...... unitWorldPosY = new Vector2( 0.0f, maxWorldPos.y / 3.0f ); // Calulate the boundary position lowerBoundWorldPosY = unitWorldPosY * (float)( -1 * numOfListBox / 2 - 1 ); upperBoundWorldPosY = unitWorldPosY * (float)( numOfListBox / 2 + 1 ); rangeBoundWorldPosY = unitWorldPosY * (float)numOfListBox; ...... }再來新增 checkBoundary() 函式,並在 initialPosition() 及 updatePosition() 中呼叫:
/* Check if the world position of the GameObject is out of the boundary. * If does, make the GameObject appear at the other slide. */ void checkBoundary() { float beyondWorldPosY = 0.0f; if ( spriteRenderer.transform.position.y < lowerBoundPosY.y ) { beyondWorldPosY = ( lowerBoundWorldPosY - transform.position.y ) % rangeBoundWorldPosY; transform.position = new Vector3( transform.position.x, upperBoundWorldPosY - unitWorldPosY - beyondWorldPosY, transform.position.z ); } else if ( spriteRenderer.transform.position.y > upperBoundPosY.y ) { beyondWorldPosY = ( transform.position.y - upperBoundWorldPosY ) % rangeBoundWorldPosY; transform.position = new Vector3( transform.position.x, lowerBoundWorldPosY + unitWorldPosY + beyondWorldPosY, transform.position.z ); } updateXPosition(); }以超出上界為例:這裡是 7 個選單物件,當選單物件超過上界時,要讓它在 -3 UnitWorldPosY.y 的地方出現,而下界的值是 -4 UnitWorldPosY.y,所以必須再加上一個 UnitWorldPosY.y,最後再補償超出的距離。至於再計算超出距離要取餘數,理由是如果超出上界的距離比上界到出現位置的距離還大時,應該還要再做一次邊界檢查,取餘數的話,可以幫助這個動作。
弧狀移動
弧狀移動的話就是以物件的 y 位置來決定 x 位置,並加上三角函數的幫助:
/* Calculate the x position accroding to the y position. */ void updateXPosition() { transform.position = new Vector3( maxWorldPos.x * 0.5 - maxWorldPos.x * 0.2f * Mathf.Cos( transform.position.y / upperBoundWorldPosY * Mathf.PI / 2.0f ), transform.position.y, transform.position.z );; }在更新 y 位置後的程式碼呼叫 updateXPositiion(),一是 updatePosition(),二是 checkBoundary() 的兩個條件判斷的 statement 中。
從圖中可以知道,當 ListBox 越往中間,與最右 x 位置的差距越大。再來是無論與正負,對於相同的 y 位置(取絕對值,其差距都是一樣的。這個特性與 Cos 函數在 +90度 到 -90度 的變化是一樣的。所以可以導出這樣的式子:
物件的 y 位置
x 位置 = 最右 x 位置 - (|最左到最右的 x 距離|) X cos( -------------------- X Pi/2 )
ListBox上界 y 位置
如此一來選單物件的 x 位置只會在 0.3 maxWorldPos.x 到 0.5 maxWorldPos.x 之間。如果將式中的負改為正,則整個選單就會向右彎曲。
2014/09/04 9:16 pm 更新
原本筆者是使用 localPosition 來控制選單物件的位置,但是後來查 API,應該使用 position 來控制位置。由於 localPosition 是相對於 parent GameObject 的位置,而 position 才是物件在 world 座標系的絕對位置。筆者在測試時,因為這些選單物件並沒有任何 parent GameObject,所以 localPosition 與 position 的效果是一樣的。
最後先附上到目前為止的程式碼:Github
教學還沒結束((躺,能看到這裡真得很有耐心(感動)。接下來談談如何顯示內容:Part 2
如果有任何問題或不足的地方,歡迎留言提問,感謝~
沒有留言:
張貼留言