This commit is contained in:
2025-08-18 09:22:24 +08:00
commit cef5623ab0
1333 changed files with 305844 additions and 0 deletions
@@ -0,0 +1,385 @@
using UnityEngine;
using UnityEngine.UI;
using System;
using UnityEngine.EventSystems;
using UnityEngine.Events;
using frame8.Logic.Misc.Visual.UI;
using frame8.Logic.Misc.Other.Extensions;
namespace Com.ForbiddenByte.OSA.Util.PullToRefresh
{
/// <summary>
/// Attach it to your ScrollView where the pull to refresh functionality is needed.
/// Browse the PullToRefreshExample scene to see how the gizmo should be set up. An image is better than 1k words.
/// </summary>
public class PullToRefreshBehaviour : MonoBehaviour, IScrollRectProxy, IBeginDragHandler, IDragHandler, IEndDragHandler
{
#region Serialized fields
/// <summary>The normalized distance relative to screen size. Always between 0 and 1</summary>
[SerializeField] [Range(.1f, 1f)] [Tooltip("The normalized distance relative to screen size. Always between 0 and 1")]
float _PullAmountNormalized = .25f;
/// <summary>The reference of the gizmo to use. If null, will try to GetComponentInChildren&lt;PullToRefreshGizmo&gt;()</summary>
[SerializeField] [Tooltip("If null, will try to GetComponentInChildren()")]
PullToRefreshGizmo _RefreshGizmo = null;
//[SerializeField]
//RectTransform.Axis _Axis;
/// <summary></summary>
[SerializeField]
bool _AllowPullFromEnd = false;
/// <summary>If false, you'll need to call HideGizmo() manually after pull. Subscribe to PullToRefreshBehaviour.OnRefresh event to know when a refresh event occurred. This method is used when the gizmo should do an animation while the refresh is executing (for ex., when some data is downloading)</summary>
[SerializeField] [Tooltip("If false, you'll need to call HideGizmo() manually after pull. Subscribe to PullToRefreshBehaviour.OnRefresh event to know when a refresh event occurred")]
bool _AutoHideRefreshGizmo = true;
#pragma warning disable 0649
/// <summary>Quick way of playing a sound effect when the pull power reaches 1f</summary>
[SerializeField]
AudioClip _SoundOnPreRefresh = null;
/// <summary>Quick way of playing a sound effect when the refresh occurred</summary>
[SerializeField]
AudioClip _SoundOnRefresh = null;
#endregion
#pragma warning restore 0649
#region Unity events
[Tooltip("Unity event fired when the pull was released")]
/// <summary>Unity event (editable in inspector) fired when the refresh occurred</summary>
public UnityEvent OnRefresh = null;
[Tooltip("Same as OnRefresh, but also gives you the refresh sign.\n" +
"1 = top, -1 = bottom")]
/// <summary>Same as <see cref="OnRefresh"/>, but also gives you the refresh sign. 1 = top, -1 = bottom</summary>
public UnityEventFloat OnRefreshWithSign = null;
[Tooltip("Unity event (editable in inspector) fired when each frame the click/finger is dragged after it has touched the ScrollView.\n" +
"Negative values indicate pulling from end")]
/// <summary>
/// Unity event (editable in inspector) fired when each frame the click/finger is dragged after it has touched the ScrollView.
/// Negative values indicate pulling from end
/// </summary>
public UnityEventFloat OnPullProgress = null;
#endregion
/// <summary>
/// Will be retrieved from the scrollrect. If not found, it can be assigned anytime before the first Update.
/// If not assigned, a default proxy will be used. The purpose of this is to allow custom implementations of ScrollRect to be used
/// </summary>
public IScrollRectProxy externalScrollRectProxy;
#region IScrollRectProxy properties implementation
public bool IsInitialized { get { return _ScrollRect != null; } }
public Vector2 Velocity { get; set; }
public bool IsHorizontal { get { return _ScrollRect.horizontal; } }
public bool IsVertical { get { return _ScrollRect.vertical; } }
public RectTransform Content { get { return _ScrollRect.content; } }
public RectTransform Viewport { get { return _ScrollRect.viewport; } }
double IScrollRectProxy.ContentInsetFromViewportStart { get { return Content.GetInsetFromParentEdge(Viewport, ScrollRectProxy.GetStartEdge()); } }
double IScrollRectProxy.ContentInsetFromViewportEnd { get { return Content.GetInsetFromParentEdge(Viewport, ScrollRectProxy.GetEndEdge()); } }
#endregion
IScrollRectProxy ScrollRectProxy { get { return externalScrollRectProxy == null ? this : externalScrollRectProxy; } }
ScrollRect _ScrollRect;
float _ResolvedAVGScreenSize;
bool _PlayedPreSoundForCurrentDrag;
//bool _IgnoreCurrentDrag;
RectTransform _RT;
int _CurrentDragSign;
StateEnum _State;
/// <summary>Not used in this default interface implementation</summary>
#pragma warning disable 0067
public event Action<double> ScrollPositionChanged = delegate { };
#pragma warning restore 0067
void Awake()
{
_RT = transform as RectTransform;
_ResolvedAVGScreenSize = (Screen.width + Screen.height) / 2f;
_ScrollRect = GetComponent<ScrollRect>();
_RefreshGizmo = GetComponentInChildren<PullToRefreshGizmo>(); // self or children
if (_ScrollRect)
{
// May be null
externalScrollRectProxy = _ScrollRect.GetComponent(typeof(IScrollRectProxy)) as IScrollRectProxy;
}
else
{
externalScrollRectProxy = GetComponentInParent(typeof(IScrollRectProxy)) as IScrollRectProxy;
if (externalScrollRectProxy == null)
{
if (enabled)
{
Debug.Log(GetType().Name + ": no scrollRect provided and found no " + typeof(IScrollRectProxy).Name + " component among ancestors. Disabling...");
enabled = false;
}
return;
}
}
}
#region IScrollRectProxy methods implementation (used if external proxy is not manually assigned)
public void SetNormalizedPosition(double normalizedPosition) { }
public double GetNormalizedPosition()
{
if (_ScrollRect.horizontal)
return _ScrollRect.horizontalNormalizedPosition;
return _ScrollRect.verticalNormalizedPosition;
}
public double GetContentSize() { return _RT.rect.size[_ScrollRect.horizontal ? 0 : 1]; }
public double GetViewportSize() { return Viewport.rect.size[_ScrollRect.horizontal ? 0 : 1]; }
public void StopMovement() { _ScrollRect.StopMovement(); }
#endregion
#region UI callbacks
public void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
if (!isActiveAndEnabled)
return;
if (_State != StateEnum.NONE)
return;
if (_RefreshGizmo.IsShown)
return;
if (!ScrollRectProxy.IsInitialized)
return;
double dragAmountNorm, _;
GetDragAmountNormalized(eventData, out dragAmountNorm, out _);
if (!_AllowPullFromEnd && dragAmountNorm < 0d)
return;
int curDragSign = Math.Sign(dragAmountNorm);
_CurrentDragSign = curDragSign;
_PlayedPreSoundForCurrentDrag = false;
_State = StateEnum.DRAGGING_WAITING_FOR_PULL;
}
public void OnDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
if (!isActiveAndEnabled)
return;
if (!ScrollRectProxy.IsInitialized)
return;
switch (_State)
{
case StateEnum.DRAGGING_WAITING_FOR_PULL:
if (!IsContentBiggerThanViewport() || IsScrollRectAtTarget(_CurrentDragSign))
{
_State = StateEnum.PULLING_WAITING_FOR_RELEASE;
goto case StateEnum.PULLING_WAITING_FOR_RELEASE;
}
return;
case StateEnum.PULLING_WAITING_FOR_RELEASE:
if (IsContentBiggerThanViewport() && !IsScrollRectAtTarget(_CurrentDragSign))
{
HideGizmoInternal();
_State = StateEnum.DRAGGING_WAITING_FOR_PULL;
return;
}
double dragAmountNorm, deltaNorm;
GetDragAmountNormalized(eventData, out dragAmountNorm, out deltaNorm);
if (Math.Sign(dragAmountNorm) != _CurrentDragSign)
{
return;
}
//if (!_AllowPullFromEnd && dragAmountNorm < 0d)
//{
// HideGizmoInternal();
// return;
//}
double pullPower = dragAmountNorm;
ShowGizmoIfNeeded();
if (_RefreshGizmo)
_RefreshGizmo.OnPull(pullPower);
if (OnPullProgress != null)
OnPullProgress.Invoke((float)pullPower);
if (Math.Abs(pullPower) >= 1d && !_PlayedPreSoundForCurrentDrag)
{
_PlayedPreSoundForCurrentDrag = true;
if (_SoundOnPreRefresh)
AudioSource.PlayClipAtPoint(_SoundOnPreRefresh, Camera.main.transform.position);
}
return;
}
//Debug.Log("eventData.pressPosition=" + eventData.pressPosition + "\n eventData.position=" + eventData.position + "\neventData.scrollDelta="+ eventData.scrollDelta);
}
public void OnEndDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;
if (!ScrollRectProxy.IsInitialized)
return;
if (_State != StateEnum.PULLING_WAITING_FOR_RELEASE)
{
if (_State == StateEnum.DRAGGING_WAITING_FOR_PULL)
{
HideGizmoInternal();
_State = StateEnum.NONE;
}
return;
}
bool proceedRefresh = false;
if (isActiveAndEnabled)
{
proceedRefresh = true;
if (IsContentBiggerThanViewport())
proceedRefresh = IsScrollRectAtTarget(_CurrentDragSign);
if (proceedRefresh)
{
double dragAmount, _;
GetDragAmountNormalized(eventData, out dragAmount, out _);
if (Math.Sign(dragAmount) != _CurrentDragSign)
proceedRefresh = false;
else if (Math.Abs(dragAmount) < 1d)
proceedRefresh = false;
}
}
if (proceedRefresh)
{
if (OnRefresh != null)
OnRefresh.Invoke();
if (OnRefreshWithSign != null)
OnRefreshWithSign.Invoke(_CurrentDragSign);
if (_RefreshGizmo)
_RefreshGizmo.OnRefreshed(_AutoHideRefreshGizmo);
if (_SoundOnRefresh)
AudioSource.PlayClipAtPoint(_SoundOnRefresh, Camera.main.transform.position);
}
else
{
if (_RefreshGizmo)
_RefreshGizmo.OnRefreshCancelled();
_State = StateEnum.NONE;
}
if (_RefreshGizmo && _RefreshGizmo.IsShown)
{
if (_AutoHideRefreshGizmo)
{
HideGizmoInternal();
_State = StateEnum.NONE;
}
else
{
_State = StateEnum.AFTER_RELEASE_WAITING_FOR_GIZMO_TO_HIDE;
}
}
}
#endregion
// sign: 1=start(top or left), -1=end(bottom or right);
bool IsScrollRectAtTarget(int targetDragSign)
{
double normPos = ScrollRectProxy.GetNormalizedPosition();
if (ScrollRectProxy.IsHorizontal)
normPos = 1d - normPos;
if (targetDragSign == 1 && normPos >= 1d)
return true;
if (targetDragSign == -1 && normPos <= 0d)
return true;
return false;
}
bool IsContentBiggerThanViewport() { return ScrollRectProxy.GetContentSize() > _RT.rect.size[ScrollRectProxy.IsHorizontal ? 0 : 1]; }
public void ShowGizmoIfNeeded()
{
if (_RefreshGizmo && !_RefreshGizmo.IsShown)
_RefreshGizmo.IsShown = true;
}
public void HideGizmo()
{
HideGizmoInternal();
if (_State == StateEnum.AFTER_RELEASE_WAITING_FOR_GIZMO_TO_HIDE)
_State = StateEnum.NONE;
}
void HideGizmoInternal()
{
if (_RefreshGizmo)
_RefreshGizmo.IsShown = false;
}
void GetDragAmountNormalized(PointerEventData eventData, out double total, out double delta)
{
total = 0d;
delta = 0d;
float pos;
float maxPullAmount = _PullAmountNormalized * _ResolvedAVGScreenSize;
if (ScrollRectProxy.IsVertical)
{
pos = eventData.position.y;
float worldDragVec = pos - eventData.pressPosition.y;
total = -worldDragVec;
delta = -eventData.delta.y;
}
else
{
pos = eventData.position.x;
float worldDragVec = pos - eventData.pressPosition.x;
total = worldDragVec;
delta = eventData.delta.x;
}
total /= maxPullAmount;
delta /= maxPullAmount;
}
enum StateEnum
{
NONE,
DRAGGING_WAITING_FOR_PULL,
PULLING_WAITING_FOR_RELEASE,
AFTER_RELEASE_WAITING_FOR_GIZMO_TO_HIDE
}
[Serializable]
public class UnityEventFloat : UnityEvent<float> { }
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8a7fcb71e0128534fb984a6449319591
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
@@ -0,0 +1,50 @@
using UnityEngine;
namespace Com.ForbiddenByte.OSA.Util.PullToRefresh
{
/// <summary> Base class for gizmos that can be used with <see cref="PullToRefreshBehaviour"/> (see it for more details). Attach it to your ScrollView </summary>
public class PullToRefreshGizmo : MonoBehaviour
{
/// <summary>Property that can be overriden by the inheritors. The default implementation is to set whether the game object is active or not</summary>
public virtual bool IsShown
{
get { return _IsShown; }
set
{
_IsShown = value;
gameObject.SetActive(_IsShown);
}
}
bool _IsShown;
public virtual void Awake()
{}
/// <summary>
/// <para>Called for each OnDrag event on the ScrollView. In other words, it's called continuously during moving the mouse/finger after the click</para>
/// </summary>
/// <param name="power">0d = didn't drag at all; .5d = dragged half-way from start; 1d = dragged from start exactly at the minimum needed point in
/// order for a refresh event to occur; values will exceed 1f after this minimum drag amount is exceeded
/// (which can be used to visualize the fact that after the click/finger is released, the refresh will occur).
/// Negative values indicate a pull from the end instead of start, and the same rules apply
/// </param>
public virtual void OnPull(double power)
{}
/// <summary> Called when the refresh did occur (dragged with at least 1f power and released)</summary>
/// <param name="autoHide">A hint for the gizmo to know whether it should hide itself or something will hide it externally by setting <see cref="IsShown"/>=false </param>
public virtual void OnRefreshed(bool autoHide)
{
if (autoHide)
IsShown = false;
}
/// <summary> Called when the click/finger was released before the pullPower reached 1f</summary>
public virtual void OnRefreshCancelled()
{ IsShown = false; }
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e3a396e6841ce6343adc9ea23c888ab8
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
@@ -0,0 +1,159 @@
using UnityEngine;
using System;
using UnityEngine.Serialization;
namespace Com.ForbiddenByte.OSA.Util.PullToRefresh
{
/// <summary>
/// <para> Implementation of <see cref="PullToRefreshGizmo"/> that uses a rotating image to show the pull progress. </para>
/// <para>The image is rotated by the amount of distance traveled by the click/finger.</para>
/// <para>When enough pulling distance is covered the gizmo enters the "ready to refresh" state,</para>
/// <para>the rotation amount applied is damped by <see cref="_ExcessPullRotationDamping"/> (i.e. a value of 1f won't apply any furter rotation, </para>
/// <para>while a value of 0f will apply the same amount of rotation per distance traveled by the click/finger as before the "ready to refresh" state).</para>
/// <para>When <see cref="OnRefreshed(bool)"/> is called with true, the gizmo will disappear; if it'll be called with false, </para>
/// <para>it'll start auto-rotating with a speed of <see cref="_AutoRotationDegreesPerSec"/> degrees per second, until <see cref="IsShown"/> is set to false.</para>
/// <para>This last use-case is very common for when the refresh event actually takes time (i.e. retrieving items from a server).</para>
/// </summary>
public class PullToRefreshRotateGizmo : PullToRefreshGizmo
{
#pragma warning disable 0649
[SerializeField]
[FormerlySerializedAs("_StartingPoint")]
RectTransform _PullFromStartInitial = null;
[SerializeField]
[FormerlySerializedAs("_EndingPoint")]
RectTransform _PullFromStartTarget = null;
[SerializeField]
RectTransform _PullFromEndInitial = null;
[SerializeField]
RectTransform _PullFromEndTarget = null;
#pragma warning restore 0649
//[Tooltip("When pulling is done from the end, this gizmo will also appear at the end, not at the start. " +
// "\nThe gizmo's position in this case is inferred using the parent's size and _StartingPoint & _EndingPoint ")]
//[SerializeField]
//bool _AllowAppearingFromEnd = true;
[SerializeField]
[Range(0f, 1f)]
float _ExcessPullRotationDamping = .95f;
[SerializeField]
float _AutoRotationDegreesPerSec = 200;
[Tooltip("Will also interpolate its own scale between the Initial's and Target's scale")]
[SerializeField]
bool _ScaleWithTarget = true;
[Tooltip("If true, it won't be affected by Time.timeScale")]
[SerializeField]
bool _UseUnscaledTime = true;
bool _WaitingForManualHide;
/// <summary>Calls base implementation + resets the rotation to default each time is assigned, regardless if true or false</summary>
public override bool IsShown
{
get { return base.IsShown; }
set
{
base.IsShown = value;
// Reset to default rotation
transform.localRotation = Quaternion.Euler(_InitialLocalRotation);
if (!value)
_WaitingForManualHide = false;
}
}
Vector3 _InitialLocalRotation, _InitialLocalScale;
Transform _TR;
public override void Awake()
{
base.Awake();
_TR = transform;
_InitialLocalRotation = _TR.localRotation.eulerAngles;
_InitialLocalScale = _TR.localScale;
}
void Update()
{
if (_WaitingForManualHide)
{
SetLocalZRotation((_TR.localEulerAngles.z - (_UseUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime) * _AutoRotationDegreesPerSec) % 360);
}
}
public override void OnPull(double power)
{
base.OnPull(power);
double powerAbs = Math.Abs(power);
int powerSign = Math.Sign(power);
float powerAbsClamped01 = Mathf.Clamp01((float)powerAbs);
float excess = Mathf.Max(0f, (float)powerAbs - 1f);
float dampedExcess = excess * (1f - _ExcessPullRotationDamping);
SetLocalZRotation((_InitialLocalRotation.z - 360 * (powerAbsClamped01 + dampedExcess)) % 360);
//_TR.position = LerpUnclamped(_StartingPoint.position, _EndingPoint.position, power <= 1f ? (power - (1f - power/2)*(1f-power/2)) : (1 - 1/(1 + excess) ));
Vector3 start, end;
Vector3 scaleStart, scaleEnd;
if (powerSign < 0 && _PullFromEndInitial && _PullFromEndTarget)
{
start = _PullFromEndInitial.position;
end = _PullFromEndTarget.position;
scaleStart = _PullFromEndInitial.localScale;
scaleEnd = _PullFromEndTarget.localScale;
}
else
{
start = _PullFromStartInitial.position;
end = _PullFromStartTarget.position;
scaleStart = _PullFromStartInitial.localScale;
scaleEnd = _PullFromStartTarget.localScale;
}
var t01Unclamped = 2 - 2 / (1 + powerAbsClamped01);
_TR.position = LerpUnclamped(start, end, t01Unclamped);
if (_ScaleWithTarget)
_TR.localScale = LerpUnclamped(scaleStart, scaleEnd, t01Unclamped);
else
_TR.localScale = _InitialLocalScale;
}
public override void OnRefreshCancelled()
{
base.OnRefreshCancelled();
_WaitingForManualHide = false;
}
public override void OnRefreshed(bool autoHide)
{
base.OnRefreshed(autoHide);
_WaitingForManualHide = !autoHide;
}
Vector3 LerpUnclamped(Vector3 from, Vector3 to, float t) { return (1f - t) * from + t * to ; }
void SetLocalZRotation(float zRotation)
{
var rotE = _TR.localRotation.eulerAngles;
rotE.z = zRotation;
_TR.localRotation = Quaternion.Euler(rotE);
}
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 26e83b34c01bd224294affa1aee5a14d
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData: