Files

505 lines
17 KiB
C#

#nullable enable
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEngine.Networking;
namespace UVC.UIToolkit
{
/// <summary>
/// 이미지 컴포넌트.
/// Resources 폴더 내부 경로 또는 HTTP/HTTPS URL에서 이미지를 로드하여 표시합니다.
/// </summary>
/// <remarks>
/// <para><b>UTKImage란?</b></para>
/// <para>
/// UTKImage는 UXML에서 직접 사용할 수 있는 이미지 표시 컴포넌트입니다.
/// <c>src</c> 속성에 경로를 지정하면 자동으로 이미지를 로드하여 표시합니다.
/// 내부 리소스(Resources 폴더)와 외부 URL(HTTP/HTTPS) 모두 지원합니다.
/// </para>
///
/// <para><b>지원하는 이미지 소스:</b></para>
/// <list type="bullet">
/// <item><description><b>내부 리소스</b> - Assets/Resources 폴더 내 이미지 경로 (확장자 제외)</description></item>
/// <item><description><b>외부 URL</b> - http:// 또는 https://로 시작하는 이미지 URL</description></item>
/// </list>
///
/// <para><b>주요 속성:</b></para>
/// <list type="bullet">
/// <item><description><c>src</c> - 이미지 소스 경로 (Resources 경로 또는 HTTP URL)</description></item>
/// <item><description><c>scale-mode</c> - 이미지 스케일 모드 (StretchToFill, ScaleAndCrop, ScaleToFit)</description></item>
/// <item><description><c>tint-color</c> - 이미지 틴트 색상</description></item>
/// </list>
///
/// <para><b>이미지 스케일 모드:</b></para>
/// <list type="bullet">
/// <item><description><c>StretchToFill</c> - 요소 크기에 맞게 늘림 (비율 무시)</description></item>
/// <item><description><c>ScaleAndCrop</c> - 비율 유지하며 요소를 채움 (잘릴 수 있음)</description></item>
/// <item><description><c>ScaleToFit</c> - 비율 유지하며 요소 안에 맞춤 (여백 발생 가능)</description></item>
/// </list>
///
/// <para><b>실제 활용 예시:</b></para>
/// <list type="bullet">
/// <item><description>프로필 이미지, 아바타</description></item>
/// <item><description>썸네일, 미리보기 이미지</description></item>
/// <item><description>배너, 광고 이미지</description></item>
/// <item><description>제품 이미지, 갤러리</description></item>
/// </list>
/// </remarks>
/// <example>
/// <para><b>C# 코드에서 사용:</b></para>
/// <code>
/// // 내부 리소스 이미지 로드
/// var image = new UTKImage();
/// image.Src = "UIToolkit/Images/icon_setting_22";
/// image.style.width = 100;
/// image.style.height = 100;
///
/// // 외부 URL 이미지 로드
/// var webImage = new UTKImage();
/// webImage.Src = "https://example.com/image.png";
/// webImage.ScaleMode = ScaleMode.ScaleToFit;
///
/// // 생성자에서 소스 지정
/// var profileImage = new UTKImage("https://api.example.com/avatar/123");
/// profileImage.style.width = 64;
/// profileImage.style.height = 64;
/// profileImage.style.borderTopLeftRadius = 32;
/// profileImage.style.borderTopRightRadius = 32;
/// profileImage.style.borderBottomLeftRadius = 32;
/// profileImage.style.borderBottomRightRadius = 32;
///
/// // 이미지 로드 이벤트 핸들링
/// image.OnImageLoaded += (texture) => Debug.Log("이미지 로드 완료");
/// image.OnImageFailed += (error) => Debug.LogError($"로드 실패: {error}");
///
/// // 틴트 색상 적용
/// image.TintColor = Color.red;
///
/// // 비동기 로드
/// await image.LoadAsync("https://example.com/image.png", cancellationToken);
/// </code>
/// <para><b>UXML에서 사용:</b></para>
/// <code>
/// <ui:UXML xmlns:utk="UVC.UIToolkit">
/// <!-- 내부 리소스 이미지 -->
/// <utk:UTKImage src="UIToolkit/Images/icon_setting_22"
/// style="width: 48px; height: 48px;" />
///
/// <!-- 외부 URL 이미지 -->
/// <utk:UTKImage src="https://example.com/image.png"
/// style="width: 200px; height: 150px;"
/// scale-mode="ScaleToFit" />
///
/// <!-- 둥근 프로필 이미지 -->
/// <utk:UTKImage src="https://api.example.com/avatar"
/// class="profile-avatar" />
///
/// <!-- 틴트 색상 적용 -->
/// <utk:UTKImage src="UIToolkit/Images/icon_home"
/// tint-color="#FF5500" />
/// </ui:UXML>
/// </code>
/// </example>
[UxmlElement]
public partial class UTKImage : VisualElement, IDisposable
{
#region Constants
private const string USS_PATH = "UIToolkit/Common/UTKImage";
#endregion
#region Fields
private bool _disposed;
private string _src = "";
private ScaleMode _scaleMode = ScaleMode.ScaleToFit;
private Color _tintColor = Color.white;
private Texture2D? _loadedTexture;
private CancellationTokenSource? _loadCts;
private bool _isExternalTexture;
#endregion
#region Events
/// <summary>이미지 로드 완료 이벤트</summary>
public event Action<Texture2D>? OnImageLoaded;
/// <summary>이미지 로드 실패 이벤트</summary>
public event Action<string>? OnImageFailed;
#endregion
#region Properties
/// <summary>
/// 이미지 소스 경로.
/// Resources 폴더 내 경로(확장자 제외) 또는 HTTP/HTTPS URL을 지정합니다.
/// </summary>
/// <example>
/// <code>
/// // 내부 리소스
/// image.Src = "UIToolkit/Images/icon_setting_22";
///
/// // 외부 URL
/// image.Src = "https://example.com/image.png";
/// </code>
/// </example>
[UxmlAttribute("src")]
public string Src
{
get => _src;
set
{
if (_src == value) return;
_src = value;
LoadImageFromSource();
}
}
/// <summary>이미지 스케일 모드</summary>
[UxmlAttribute("scale-mode")]
public ScaleMode ScaleMode
{
get => _scaleMode;
set
{
_scaleMode = value;
UpdateBackgroundScaleMode();
}
}
/// <summary>이미지 틴트 색상</summary>
[UxmlAttribute("tint-color")]
public Color TintColor
{
get => _tintColor;
set
{
_tintColor = value;
style.unityBackgroundImageTintColor = value;
}
}
/// <summary>현재 로드된 텍스처</summary>
public Texture2D? LoadedTexture => _loadedTexture;
/// <summary>이미지 로드 중 여부</summary>
public bool IsLoading => _loadCts != null && !_loadCts.IsCancellationRequested;
#endregion
#region Constructor
public UTKImage()
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
{
styleSheets.Add(uss);
}
SetupStyles();
SubscribeToThemeChanges();
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
}
/// <summary>
/// 소스를 지정하여 이미지를 생성합니다.
/// </summary>
/// <param name="src">이미지 소스 경로 (Resources 경로 또는 HTTP URL)</param>
public UTKImage(string src) : this()
{
_src = src;
}
/// <summary>
/// Texture2D로 직접 이미지를 생성합니다.
/// </summary>
/// <param name="texture">표시할 텍스처</param>
public UTKImage(Texture2D texture) : this()
{
SetTexture(texture);
}
#endregion
#region Setup
private void SetupStyles()
{
AddToClassList("utk-image");
style.unityBackgroundScaleMode = _scaleMode;
}
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnAttachToPanel(AttachToPanelEvent evt)
{
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanel);
// UXML에서 설정된 src가 있으면 로드
if (!string.IsNullOrEmpty(_src))
{
LoadImageFromSource();
}
}
#endregion
#region Image Loading
private void LoadImageFromSource()
{
if (string.IsNullOrEmpty(_src))
{
ClearImage();
return;
}
// 이전 로드 취소
CancelCurrentLoad();
if (IsExternalUrl(_src))
{
LoadFromUrlAsync(_src).Forget();
}
else
{
LoadFromResources(_src);
}
}
/// <summary>
/// Resources 폴더에서 이미지를 동기로 로드합니다.
/// </summary>
/// <param name="resourcePath">Resources 폴더 내 경로 (확장자 제외)</param>
public void LoadFromResources(string resourcePath)
{
var texture = Resources.Load<Texture2D>(resourcePath);
if (texture != null)
{
_isExternalTexture = false;
ApplyTexture(texture);
OnImageLoaded?.Invoke(texture);
}
else
{
var errorMsg = $"리소스를 찾을 수 없습니다: {resourcePath}";
Debug.LogWarning($"[UTKImage] {errorMsg}");
OnImageFailed?.Invoke(errorMsg);
}
}
/// <summary>
/// Resources 폴더에서 이미지를 비동기로 로드합니다.
/// </summary>
/// <param name="resourcePath">Resources 폴더 내 경로 (확장자 제외)</param>
/// <param name="ct">취소 토큰</param>
public async UniTask LoadFromResourcesAsync(string resourcePath, CancellationToken ct = default)
{
CancelCurrentLoad();
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
var request = Resources.LoadAsync<Texture2D>(resourcePath);
await request.ToUniTask(cancellationToken: _loadCts.Token);
var texture = request.asset as Texture2D;
if (texture != null)
{
_isExternalTexture = false;
ApplyTexture(texture);
OnImageLoaded?.Invoke(texture);
}
else
{
var errorMsg = $"리소스를 찾을 수 없습니다: {resourcePath}";
Debug.LogWarning($"[UTKImage] {errorMsg}");
OnImageFailed?.Invoke(errorMsg);
}
}
catch (OperationCanceledException)
{
// 취소됨
}
finally
{
_loadCts?.Dispose();
_loadCts = null;
}
}
/// <summary>
/// 외부 URL에서 이미지를 비동기로 로드합니다.
/// </summary>
/// <param name="url">이미지 URL (http:// 또는 https://)</param>
/// <param name="ct">취소 토큰</param>
public async UniTask LoadFromUrlAsync(string url, CancellationToken ct = default)
{
CancelCurrentLoad();
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
AddToClassList("utk-image--loading");
using var request = UnityWebRequestTexture.GetTexture(url);
await request.SendWebRequest().ToUniTask(cancellationToken: _loadCts.Token);
if (request.result == UnityWebRequest.Result.Success)
{
var texture = DownloadHandlerTexture.GetContent(request);
if (texture != null)
{
// 이전 외부 텍스처 정리
CleanupExternalTexture();
_isExternalTexture = true;
ApplyTexture(texture);
OnImageLoaded?.Invoke(texture);
}
}
else
{
var errorMsg = $"이미지 로드 실패: {request.error}";
Debug.LogWarning($"[UTKImage] {errorMsg}");
OnImageFailed?.Invoke(errorMsg);
}
}
catch (OperationCanceledException)
{
// 취소됨
}
catch (Exception ex)
{
var errorMsg = $"이미지 로드 중 오류: {ex.Message}";
Debug.LogError($"[UTKImage] {errorMsg}");
OnImageFailed?.Invoke(errorMsg);
}
finally
{
RemoveFromClassList("utk-image--loading");
_loadCts?.Dispose();
_loadCts = null;
}
}
/// <summary>
/// 소스에서 이미지를 비동기로 로드합니다.
/// Resources 경로 또는 HTTP URL을 자동으로 감지합니다.
/// </summary>
/// <param name="source">이미지 소스 (Resources 경로 또는 HTTP URL)</param>
/// <param name="ct">취소 토큰</param>
public async UniTask LoadAsync(string source, CancellationToken ct = default)
{
_src = source;
if (IsExternalUrl(source))
{
await LoadFromUrlAsync(source, ct);
}
else
{
await LoadFromResourcesAsync(source, ct);
}
}
/// <summary>
/// Texture2D를 직접 설정합니다.
/// </summary>
/// <param name="texture">표시할 텍스처</param>
public void SetTexture(Texture2D texture)
{
CleanupExternalTexture();
_isExternalTexture = false;
ApplyTexture(texture);
}
/// <summary>
/// 이미지를 제거합니다.
/// </summary>
public void ClearImage()
{
CleanupExternalTexture();
_loadedTexture = null;
style.backgroundImage = StyleKeyword.None;
}
#endregion
#region Private Methods
private void ApplyTexture(Texture2D texture)
{
_loadedTexture = texture;
style.backgroundImage = new StyleBackground(texture);
style.unityBackgroundScaleMode = _scaleMode;
style.unityBackgroundImageTintColor = _tintColor;
}
private void UpdateBackgroundScaleMode()
{
style.unityBackgroundScaleMode = _scaleMode;
}
private void CancelCurrentLoad()
{
if (_loadCts != null)
{
_loadCts.Cancel();
_loadCts.Dispose();
_loadCts = null;
}
}
private void CleanupExternalTexture()
{
if (_isExternalTexture && _loadedTexture != null)
{
UnityEngine.Object.Destroy(_loadedTexture);
_loadedTexture = null;
_isExternalTexture = false;
}
}
private static bool IsExternalUrl(string source)
{
return source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
CancelCurrentLoad();
CleanupExternalTexture();
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
OnImageLoaded = null;
OnImageFailed = null;
}
#endregion
}
}