|
unit CustomCardGrid; |
|
|
|
interface |
|
|
|
uses |
|
System.SysUtils, System.Types, System.UITypes, System.Classes, |
|
System.Generics.Collections, System.Math, FMX.Types, FMX.Controls, |
|
FMX.Graphics, FMX.Objects, FMX.Layouts, FMX.Ani, FMX.Forms, FMX.Platform, |
|
System.Skia, FMX.Skia.Canvas, MTGCore, CardPriceTypes; |
|
|
|
type |
|
TGridCardData = class |
|
private |
|
FImageSkia: ISkImage; |
|
FPriceData: TCardPriceData; |
|
FHasPriceData: Boolean; |
|
CachedDisplayPrice: string; |
|
public |
|
UUID: string; |
|
Name: string; |
|
SetCode: string; |
|
Number: string; |
|
SetAndNum:string; |
|
ScryfallId: string; |
|
ImageLoading: Boolean; |
|
AnimationProgress: Single; |
|
FirstVisible: Boolean; |
|
IsOnlineOnly: Boolean; |
|
|
|
constructor Create(const ACard: TCard); |
|
destructor Destroy; override; |
|
|
|
procedure UpdatePriceData(const APriceData: TCardPriceData); |
|
function GetDisplayPrice: string; |
|
|
|
property ImageSkia: ISkImage read FImageSkia write FImageSkia; |
|
property PriceData: TCardPriceData read FPriceData; |
|
property HasPriceData: Boolean read FHasPriceData; |
|
end; |
|
|
|
TCustomCardGrid = class(TControl) |
|
private |
|
FCards: TObjectList<TGridCardData>; |
|
FCardWidth: Single; |
|
FCardHeight: Single; |
|
FMinCardWidth: Single; |
|
FColumnCount: Integer; |
|
FSpacing: Single; |
|
FHoveredCard: Integer; |
|
FScrollOffset: Single; |
|
FOnCardClick: TNotifyEvent; |
|
FOnCardMouseDown: TNotifyEvent; |
|
FClickedCardUUID: string; |
|
FPlaceholderBitmap: TBitmap; |
|
FVisibleIndexStart: Integer; |
|
FVisibleIndexEnd: Integer; |
|
FAnimProgress: Single; |
|
FMouseDownPos: TPointF; |
|
FMouseMoved: Boolean; |
|
FScreenScale: Single; |
|
FLastCleanupTime: UInt64; |
|
FRepaintScheduled: Boolean; |
|
FOnCardLongPress: TNotifyEvent; |
|
FLongPressCard: Integer; |
|
FPriceLoadInProgress: Boolean; |
|
FPaint: ISkPaint; |
|
FBorderPaint: ISkPaint; |
|
|
|
// Animation properties |
|
FHoverScaleProgress: Single; |
|
FShimmerProgress: Single; |
|
FRippleProgress: Single; |
|
FRippleCardIndex: Integer; |
|
|
|
procedure CalculateLayout; |
|
function GetCardRect(Index: Integer): TRectF; |
|
function CardAtPoint(const APoint: TPointF): Integer; |
|
procedure CreatePlaceholder; |
|
procedure CalculateVisibleIndices; |
|
procedure CheckForNewAnimations; |
|
procedure SetAnimProgress(const Value: Single); |
|
procedure SetHoverScaleProgress(const Value: Single); |
|
procedure SetShimmerProgress(const Value: Single); |
|
procedure SetRippleProgress(const Value: Single); |
|
procedure UpdateScreenScale; |
|
procedure CleanupOffScreenBitmaps; |
|
procedure StartShimmerAnimation; |
|
procedure LoadVisibleCardPrices; |
|
protected |
|
procedure Paint; override; |
|
procedure DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); override; |
|
procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; |
|
procedure MouseMove(Shift: TShiftState; X, Y: Single); override; |
|
procedure MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; |
|
procedure Resize; override; |
|
public |
|
constructor Create(AOwner: TComponent); override; |
|
destructor Destroy; override; |
|
|
|
procedure SetCards(const ACards: TArray<TCard>); |
|
procedure AddCards(const ACards: TArray<TCard>); |
|
procedure ClearCards; |
|
function IsCardInRecentBuffer(const ACardUUID: string): Boolean; |
|
procedure SetScrollOffset(AOffset: Single); |
|
procedure UpdateCardImage(const ACardUUID: string; const AImage: ISkImage); |
|
function GetCardUUIDAt(Index: Integer): string; |
|
function GetCardCount: Integer; |
|
function HasCardImage(const ACardUUID: string): Boolean; |
|
procedure RefreshPrices; |
|
|
|
property OnCardLongPress: TNotifyEvent read FOnCardLongPress write FOnCardLongPress; |
|
property OnCardClick: TNotifyEvent read FOnCardClick write FOnCardClick; |
|
property OnCardMouseDown: TNotifyEvent read FOnCardMouseDown write FOnCardMouseDown; |
|
property ClickedCardUUID: string read FClickedCardUUID; |
|
property AnimProgress: Single read FAnimProgress write SetAnimProgress; |
|
property HoverScaleProgress: Single read FHoverScaleProgress write SetHoverScaleProgress; |
|
property ShimmerProgress: Single read FShimmerProgress write SetShimmerProgress; |
|
property RippleProgress: Single read FRippleProgress write SetRippleProgress; |
|
property MinCardWidth: Single read FMinCardWidth write FMinCardWidth; |
|
property ColumnCount: Integer read FColumnCount; |
|
property CardHeight: Single read FCardHeight; |
|
property Spacing: Single read FSpacing; |
|
end; |
|
|
|
implementation |
|
|
|
uses |
|
System.Threading, CardPriceManager; |
|
|
|
const |
|
CARD_IMAGE_RATIO = 1.3968; |
|
LABEL_HEIGHT = 42; |
|
ANIMATION_DURATION = 0.3; |
|
CLEANUP_INTERVAL_SECONDS = 2.0; |
|
OFFSCREEN_BUFFER_CARDS = 15; |
|
|
|
function MakeSkiaColor(const AColor: TAlphaColor; const AOpacity: Single): TAlphaColor; |
|
var |
|
ColorRec: TAlphaColorRec; |
|
begin |
|
ColorRec := TAlphaColorRec(AColor); |
|
ColorRec.A := Round(ColorRec.A * EnsureRange(AOpacity, 0.0, 1.0)); |
|
Result := ColorRec.Color; |
|
end; |
|
|
|
{ TGridCardData } |
|
|
|
constructor TGridCardData.Create(const ACard: TCard); |
|
begin |
|
inherited Create; |
|
UUID := ACard.UUID; |
|
Name := ACard.Name; |
|
IsOnlineOnly := ACard.IsOnlineOnly; |
|
SetCode := ACard.SetCode; |
|
Number := ACard.Number; |
|
SetAndNum:= ACard.GetSetAndNumber; |
|
ScryfallId := ACard.ScryfallId; |
|
ImageLoading := False; |
|
AnimationProgress := 0.0; |
|
FirstVisible := False; |
|
FHasPriceData := False; |
|
FPriceData := TCardPriceData.Empty; |
|
end; |
|
|
|
destructor TGridCardData.Destroy; |
|
begin |
|
FImageSkia := nil; |
|
inherited; |
|
end; |
|
|
|
procedure TGridCardData.UpdatePriceData(const APriceData: TCardPriceData); |
|
begin |
|
FPriceData := APriceData; |
|
FHasPriceData := True; |
|
|
|
// Calculate the string ONCE here |
|
CachedDisplayPrice := GetDisplayPrice; |
|
end; |
|
|
|
function TGridCardData.GetDisplayPrice: string; |
|
var |
|
Paper: TPaperPlatform; |
|
|
|
// Small helper function to handle the EUR/USD formatting cleanly |
|
function FormatPrice(Price: Double; Cur: TCurrency): string; |
|
begin |
|
if Cur = curEUR then |
|
Result := Format('€%.2f', [Price]) |
|
else |
|
Result := Format('$%.2f', [Price]); |
|
end; |
|
|
|
begin |
|
if not FHasPriceData then |
|
Exit(''); |
|
|
|
Paper := FPriceData.Paper; |
|
|
|
// 1. Try TCGPlayer (Normal, then Foil) |
|
if Paper.TCGPlayer.RetailNormal.Price > 0 then |
|
Exit(FormatPrice(Paper.TCGPlayer.RetailNormal.Price, Paper.TCGPlayer.Currency)); |
|
if Paper.TCGPlayer.RetailFoil.Price > 0 then |
|
Exit(FormatPrice(Paper.TCGPlayer.RetailFoil.Price, Paper.TCGPlayer.Currency)); |
|
|
|
// 2. Try Cardmarket (Normal, then Foil) |
|
if Paper.Cardmarket.RetailNormal.Price > 0 then |
|
Exit(FormatPrice(Paper.Cardmarket.RetailNormal.Price, Paper.Cardmarket.Currency)); |
|
if Paper.Cardmarket.RetailFoil.Price > 0 then |
|
Exit(FormatPrice(Paper.Cardmarket.RetailFoil.Price, Paper.Cardmarket.Currency)); |
|
|
|
// 3. Try Card Kingdom (Normal, then Foil) |
|
if Paper.CardKingdom.RetailNormal.Price > 0 then |
|
Exit(FormatPrice(Paper.CardKingdom.RetailNormal.Price, Paper.CardKingdom.Currency)); |
|
if Paper.CardKingdom.RetailFoil.Price > 0 then |
|
Exit(FormatPrice(Paper.CardKingdom.RetailFoil.Price, Paper.CardKingdom.Currency)); |
|
|
|
// 4. Try ManaPool (Normal, then Foil) |
|
if Paper.ManaPool.RetailNormal.Price > 0 then |
|
Exit(FormatPrice(Paper.ManaPool.RetailNormal.Price, Paper.ManaPool.Currency)); |
|
if Paper.ManaPool.RetailFoil.Price > 0 then |
|
Exit(FormatPrice(Paper.ManaPool.RetailFoil.Price, Paper.ManaPool.Currency)); |
|
|
|
// If absolutely no prices are found across any vendor |
|
Result := ''; |
|
end; |
|
|
|
{ TCustomCardGrid } |
|
|
|
constructor TCustomCardGrid.Create(AOwner: TComponent); |
|
begin |
|
inherited; |
|
FCards := TObjectList<TGridCardData>.Create(True); // OwnsObjects = True |
|
Touch.InteractiveGestures := [TInteractiveGesture.LongTap]; |
|
FMinCardWidth := 100; |
|
FCardWidth := 150; |
|
FCardHeight := 210; |
|
FSpacing := 8; |
|
FHoveredCard := -1; |
|
FScrollOffset := 0; |
|
FColumnCount := 3; |
|
FVisibleIndexStart := 0; |
|
FVisibleIndexEnd := 0; |
|
FAnimProgress := 0; |
|
FMouseDownPos := TPointF.Zero; |
|
FMouseMoved := False; |
|
FLastCleanupTime := 0; |
|
HitTest := True; |
|
FRepaintScheduled := False; |
|
FLongPressCard := -1; |
|
FPriceLoadInProgress := False; |
|
// paint once |
|
FPaint := TSkPaint.Create; |
|
FPaint.AntiAlias := True; |
|
|
|
FBorderPaint := TSkPaint.Create; |
|
FBorderPaint.Style := TSkPaintStyle.Stroke; |
|
FBorderPaint.StrokeWidth := 2; |
|
FBorderPaint.AntiAlias := True; |
|
// Initialize animation properties |
|
FHoverScaleProgress := 0; |
|
FShimmerProgress := 0; |
|
FRippleProgress := 0; |
|
FRippleCardIndex := -1; |
|
|
|
UpdateScreenScale; |
|
CreatePlaceholder; |
|
StartShimmerAnimation; |
|
end; |
|
|
|
destructor TCustomCardGrid.Destroy; |
|
begin |
|
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'RippleProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'AnimProgress'); |
|
|
|
FCards.Free; // TObjectList handles all TGridCardData destruction |
|
|
|
if Assigned(FPlaceholderBitmap) then |
|
FPlaceholderBitmap.Free; |
|
FPaint := nil; |
|
FBorderPaint := nil; |
|
inherited; |
|
end; |
|
|
|
procedure TCustomCardGrid.UpdateScreenScale; |
|
var |
|
ScreenService: IFMXScreenService; |
|
begin |
|
FScreenScale := 1.0; |
|
if TPlatformServices.Current.SupportsPlatformService(IFMXScreenService, ScreenService) then |
|
FScreenScale := ScreenService.GetScreenScale; |
|
|
|
{$IFDEF ANDROID} |
|
if FScreenScale > 2.0 then |
|
FScreenScale := 2.0; |
|
{$ENDIF} |
|
{$IFDEF IOS} |
|
if FScreenScale > 2.0 then |
|
FScreenScale := 2.0; |
|
{$ENDIF} |
|
end; |
|
|
|
procedure TCustomCardGrid.CreatePlaceholder; |
|
begin |
|
FPlaceholderBitmap := TBitmap.Create(Round(150 * FScreenScale), Round((210 - LABEL_HEIGHT) * FScreenScale)); |
|
FPlaceholderBitmap.Clear(TAlphaColors.Dimgray); |
|
|
|
if FPlaceholderBitmap.Canvas.BeginScene then |
|
try |
|
FPlaceholderBitmap.Canvas.Fill.Color := TAlphaColors.White; |
|
FPlaceholderBitmap.Canvas.Font.Size := 14 * FScreenScale; |
|
FPlaceholderBitmap.Canvas.FillText(RectF(0, 0, FPlaceholderBitmap.Width, FPlaceholderBitmap.Height), 'Loading...', False, 1.0, [], TTextAlign.Center, TTextAlign.Center); |
|
finally |
|
FPlaceholderBitmap.Canvas.EndScene; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.CleanupOffScreenBitmaps; |
|
var |
|
I: Integer; |
|
// CurrentTime: TDateTime; |
|
BufferStart, BufferEnd: Integer; |
|
TotalCards: Integer; |
|
CurrentTick: UInt64; |
|
begin |
|
CurrentTick := TThread.GetTickCount64; |
|
|
|
// CLEANUP_INTERVAL_SECONDS is 2.0, so we compare against 2000ms |
|
if (CurrentTick - FLastCleanupTime) < (CLEANUP_INTERVAL_SECONDS * 1000) then |
|
Exit; |
|
|
|
FLastCleanupTime := CurrentTick; |
|
|
|
TotalCards := FCards.Count; |
|
if TotalCards = 0 then |
|
Exit; |
|
|
|
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS); |
|
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS); |
|
|
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if (I < BufferStart) or (I > BufferEnd) then |
|
begin |
|
if Assigned(FCards[I].ImageSkia) then |
|
FCards[I].ImageSkia := nil; // Direct mutation - no copy needed |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.LoadVisibleCardPrices; |
|
var |
|
SnapUUIDs: TArray<string>; |
|
SnapIndices: TArray<Integer>; |
|
I, ReqCount: Integer; |
|
begin |
|
if FPriceLoadInProgress then Exit; |
|
if not PriceManager.HasPriceData then Exit; |
|
|
|
// Capture snapshot of cards needing prices on UI thread (safe FCards access). |
|
// This avoids accessing FCards or TGridCardData objects from the background |
|
// thread, eliminating the race condition that caused crashes when gesture |
|
// events modified card state between TThread.Synchronize calls. |
|
ReqCount := 0; |
|
SetLength(SnapUUIDs, Max(0, FVisibleIndexEnd - FVisibleIndexStart + 1)); |
|
SetLength(SnapIndices, Length(SnapUUIDs)); |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I >= FCards.Count then Break; |
|
if not FCards[I].HasPriceData then |
|
begin |
|
SnapUUIDs[ReqCount] := FCards[I].UUID; |
|
SnapIndices[ReqCount] := I; |
|
Inc(ReqCount); |
|
end; |
|
end; |
|
|
|
if ReqCount = 0 then Exit; |
|
|
|
SetLength(SnapUUIDs, ReqCount); |
|
SetLength(SnapIndices, ReqCount); |
|
|
|
FPriceLoadInProgress := True; |
|
|
|
TTask.Run(procedure |
|
var |
|
PriceResults: TArray<TCardPriceData>; |
|
ResultUUIDs: TArray<string>; |
|
ResultIndices: TArray<Integer>; |
|
ResCount, J: Integer; |
|
Prices: TCardPriceData; |
|
begin |
|
try |
|
SetLength(PriceResults, ReqCount); |
|
SetLength(ResultUUIDs, ReqCount); |
|
SetLength(ResultIndices, ReqCount); |
|
ResCount := 0; |
|
|
|
// Background thread: only works with captured UUIDs, never touches FCards |
|
for J := 0 to ReqCount - 1 do |
|
begin |
|
if PriceManager.GetCardPrices(SnapUUIDs[J], Prices) then |
|
begin |
|
ResultUUIDs[ResCount] := SnapUUIDs[J]; |
|
ResultIndices[ResCount] := SnapIndices[J]; |
|
PriceResults[ResCount] := Prices; |
|
Inc(ResCount); |
|
end; |
|
end; |
|
|
|
// Apply all results in a single Synchronize call on UI thread |
|
TThread.Synchronize(nil, procedure |
|
var |
|
K: Integer; |
|
NeedsRepaint: Boolean; |
|
begin |
|
NeedsRepaint := False; |
|
for K := 0 to ResCount - 1 do |
|
begin |
|
if (ResultIndices[K] >= 0) and (ResultIndices[K] < FCards.Count) then |
|
begin |
|
// Verify card at this index hasn't changed since snapshot |
|
if FCards[ResultIndices[K]].UUID = ResultUUIDs[K] then |
|
begin |
|
FCards[ResultIndices[K]].UpdatePriceData(PriceResults[K]); |
|
NeedsRepaint := True; |
|
end; |
|
end; |
|
end; |
|
|
|
FPriceLoadInProgress := False; |
|
if NeedsRepaint then |
|
InvalidateRect(LocalRect); |
|
end); |
|
except |
|
// Ensure flag is reset even if an error occurs |
|
TThread.Synchronize(nil, procedure |
|
begin |
|
FPriceLoadInProgress := False; |
|
end); |
|
end; |
|
end); |
|
end; |
|
|
|
procedure TCustomCardGrid.RefreshPrices; |
|
var |
|
I: Integer; |
|
begin |
|
// Clear cached prices and reload |
|
for I := 0 to FCards.Count - 1 do |
|
FCards[I].FHasPriceData := False; |
|
|
|
LoadVisibleCardPrices; |
|
end; |
|
|
|
procedure TCustomCardGrid.CalculateLayout; |
|
var |
|
RowCount: Integer; |
|
NewHeight: Single; |
|
ParentWidth: Single; |
|
ImageAreaHeight: Single; |
|
begin |
|
if FCards.Count = 0 then |
|
begin |
|
Width := 300; |
|
Height := 100; |
|
Exit; |
|
end; |
|
|
|
if Assigned(Parent) and (Parent is TControl) then |
|
ParentWidth := (Parent as TControl).Width |
|
else |
|
ParentWidth := Width; |
|
|
|
ParentWidth := ParentWidth - 20; |
|
if ParentWidth <= 0 then |
|
ParentWidth := 300; |
|
|
|
FColumnCount := Floor((ParentWidth - FSpacing) / (FMinCardWidth + FSpacing)); |
|
if FColumnCount < 1 then |
|
FColumnCount := 1; |
|
|
|
FCardWidth := (ParentWidth - (FSpacing * (FColumnCount + 1))) / FColumnCount; |
|
|
|
ImageAreaHeight := FCardWidth * CARD_IMAGE_RATIO; |
|
FCardHeight := ImageAreaHeight + LABEL_HEIGHT; |
|
|
|
RowCount := Ceil(FCards.Count / FColumnCount); |
|
NewHeight := (RowCount * (FCardHeight + FSpacing)) + FSpacing; |
|
|
|
if (Width <> ParentWidth + 20) or (Height <> NewHeight) then |
|
begin |
|
Width := ParentWidth + 20; |
|
Height := NewHeight; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.CalculateVisibleIndices; |
|
var |
|
ViewportTop, ViewportBottom: Single; |
|
FirstRow, LastRow: Integer; |
|
CalcEnd: Integer; |
|
begin |
|
if Assigned(Parent) and (Parent is TControl) then |
|
ViewportBottom := FScrollOffset + (Parent as TControl).Height |
|
else |
|
ViewportBottom := FScrollOffset + 600; |
|
|
|
ViewportTop := FScrollOffset; |
|
|
|
FirstRow := Max(0, Floor((ViewportTop - FSpacing) / (FCardHeight + FSpacing)) - 1); |
|
LastRow := Min(Ceil(FCards.Count / FColumnCount) - 1, Ceil((ViewportBottom + FSpacing) / (FCardHeight + FSpacing)) + 1); |
|
|
|
FVisibleIndexStart := Max(0, FirstRow * FColumnCount); |
|
|
|
CalcEnd := (LastRow + 1) * FColumnCount - 1; |
|
FVisibleIndexEnd := Min(FCards.Count - 1, CalcEnd); |
|
|
|
if LastRow >= (Ceil(FCards.Count / FColumnCount) - 2) then |
|
FVisibleIndexEnd := FCards.Count - 1; |
|
end; |
|
|
|
procedure TCustomCardGrid.CheckForNewAnimations; |
|
var |
|
I: Integer; |
|
HasNewCards: Boolean; |
|
begin |
|
HasNewCards := False; |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I < FCards.Count then |
|
begin |
|
if not FCards[I].FirstVisible then |
|
begin |
|
FCards[I].FirstVisible := True; |
|
FCards[I].AnimationProgress := 0.0; |
|
HasNewCards := True; |
|
end; |
|
end; |
|
end; |
|
|
|
if HasNewCards then |
|
begin |
|
FAnimProgress := 0; |
|
TAnimator.StopPropertyAnimation(Self, 'AnimProgress'); |
|
TAnimator.AnimateFloat(Self, 'AnimProgress', 1.0, ANIMATION_DURATION, TAnimationType.in, TInterpolationType.Back); |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetAnimProgress(const Value: Single); |
|
var |
|
I: Integer; |
|
HasChanges: Boolean; |
|
begin |
|
if Abs(FAnimProgress - Value) < 0.02 then |
|
Exit; |
|
|
|
FAnimProgress := Value; |
|
HasChanges := False; |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I < FCards.Count then |
|
begin |
|
if FCards[I].FirstVisible and (FCards[I].AnimationProgress < 0.99) then |
|
begin |
|
FCards[I].AnimationProgress := Value; |
|
HasChanges := True; |
|
end; |
|
end; |
|
end; |
|
|
|
if HasChanges then |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetHoverScaleProgress(const Value: Single); |
|
begin |
|
if Abs(FHoverScaleProgress - Value) < 0.01 then |
|
Exit; |
|
FHoverScaleProgress := Value; |
|
if FHoveredCard >= 0 then |
|
InvalidateRect(GetCardRect(FHoveredCard)); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetShimmerProgress(const Value: Single); |
|
begin |
|
if FShimmerProgress = Value then |
|
Exit; |
|
FShimmerProgress := Value; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetRippleProgress(const Value: Single); |
|
begin |
|
FRippleProgress := Value; |
|
if (FRippleCardIndex >= 0) and (FRippleCardIndex < FCards.Count) then |
|
InvalidateRect(GetCardRect(FRippleCardIndex)); |
|
end; |
|
|
|
procedure TCustomCardGrid.StartShimmerAnimation; |
|
begin |
|
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress'); |
|
TAnimator.AnimateFloat(Self, 'ShimmerProgress', 1.0, 1.5, TAnimationType.in, TInterpolationType.Linear); |
|
end; |
|
|
|
function TCustomCardGrid.GetCardRect(Index: Integer): TRectF; |
|
var |
|
Col, Row: Integer; |
|
X, Y: Single; |
|
ScaleFactor: Single; |
|
begin |
|
Col := Index mod FColumnCount; |
|
Row := Index div FColumnCount; |
|
|
|
X := FSpacing + Col * (FCardWidth + FSpacing); |
|
Y := FSpacing + Row * (FCardHeight + FSpacing); |
|
|
|
Result := RectF(X, Y, X + FCardWidth, Y + FCardHeight); |
|
|
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
if Index = FHoveredCard then |
|
begin |
|
ScaleFactor := 1.0 + (FHoverScaleProgress * 0.05); |
|
Result.Inflate((FCardWidth * (ScaleFactor - 1)) / 2, (FCardHeight * (ScaleFactor - 1)) / 2); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
function TCustomCardGrid.CardAtPoint(const APoint: TPointF): Integer; |
|
var |
|
Col, Row: Integer; |
|
begin |
|
Result := -1; |
|
if (APoint.X < FSpacing) or (APoint.X > Width - FSpacing) then |
|
Exit; |
|
|
|
Row := Floor((APoint.Y - FSpacing) / (FCardHeight + FSpacing)); |
|
Col := Floor((APoint.X - FSpacing) / (FCardWidth + FSpacing)); |
|
|
|
if (Row >= 0) and (Col >= 0) and (Col < FColumnCount) then |
|
begin |
|
Result := Row * FColumnCount + Col; |
|
if Result >= FCards.Count then |
|
Result := -1; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.Paint; |
|
var |
|
I: Integer; |
|
CardRect, ImageRect, TextRect, LabelRect, ChipRect: TRectF; |
|
Card: TGridCardData; |
|
CardOpacity: Single; |
|
LSkCanvas: ISkCanvas; |
|
// FPaint, LBorderPaint: ISkPaint; |
|
PriceText: string; |
|
begin |
|
inherited; |
|
|
|
if FCards.Count = 0 then |
|
Exit; |
|
|
|
CleanupOffScreenBitmaps; |
|
|
|
try |
|
LSkCanvas := TSkCanvasCustom(Canvas).Canvas; |
|
LSkCanvas.Clear(TAlphaColors.Black); |
|
|
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I >= FCards.Count then |
|
Break; |
|
|
|
Card := FCards[I]; |
|
CardRect := GetCardRect(I); |
|
|
|
CardOpacity := Card.AnimationProgress; |
|
if Card.AnimationProgress >= 0.99 then |
|
CardOpacity := 1.0; |
|
|
|
// 1. CARD BACKGROUND |
|
FPaint.Color := MakeSkiaColor($FF1A1A1A, CardOpacity); |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FPaint); |
|
|
|
// 2. CARD IMAGE AREA |
|
ImageRect := RectF(CardRect.Left, CardRect.Top, CardRect.Right, CardRect.Bottom - LABEL_HEIGHT); |
|
|
|
if Assigned(Card.ImageSkia) then |
|
begin |
|
FPaint.AlphaF := CardOpacity; |
|
LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium); |
|
FPaint.AlphaF := 1.0; |
|
|
|
// Draw price chip if available |
|
if Card.HasPriceData and Card.IsOnlineOnly = False then |
|
begin |
|
PriceText := Card.CachedDisplayPrice; |
|
if PriceText <> '' then |
|
begin |
|
|
|
ChipRect := TRectF.Create( |
|
ImageRect.CenterPoint.X - 30, |
|
ImageRect.Bottom - 20, |
|
ImageRect.CenterPoint.X + 30, |
|
ImageRect.Bottom - 0 |
|
); |
|
// Chip background (semi-transparent dark) |
|
FPaint.Shader := nil; |
|
FPaint.Color := MakeSkiaColor($DD000000, CardOpacity); // 87% opacity black |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(ChipRect, 10, 10), FPaint); |
|
|
|
// Price text |
|
ChipRect.Inflate(-4, 0); |
|
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity); |
|
Canvas.Font.Size := 9; |
|
Canvas.Font.Style := [TFontStyle.fsBold]; |
|
Canvas.FillText(ChipRect, PriceText, False, CardOpacity, |
|
[], TTextAlign.Center, TTextAlign.Center); |
|
end; |
|
end; |
|
end |
|
else |
|
begin |
|
// ANIMATED SHIMMER for loading placeholder |
|
FPaint.Shader := TSkShader.MakeGradientLinear( |
|
TPointF.Create(ImageRect.Left, ImageRect.Top), |
|
TPointF.Create(ImageRect.Right, ImageRect.Top), |
|
TArray<TAlphaColor>.Create( |
|
MakeSkiaColor($FF2A2A2A, CardOpacity), |
|
MakeSkiaColor($FF3A3A3A, CardOpacity), |
|
MakeSkiaColor($FF2A2A2A, CardOpacity) |
|
), |
|
TArray<Single>.Create( |
|
Max(0.0, FShimmerProgress - 0.3), |
|
FShimmerProgress, |
|
Min(1.0, FShimmerProgress + 0.3) |
|
), |
|
TSkTileMode.Clamp |
|
); |
|
LSkCanvas.DrawRect(ImageRect, FPaint); |
|
FPaint.Shader := nil; |
|
end; |
|
|
|
// 3. LABEL BACKGROUND |
|
LabelRect := RectF(CardRect.Left, CardRect.Bottom - LABEL_HEIGHT, CardRect.Right, CardRect.Bottom); |
|
FPaint.Color := MakeSkiaColor($FF0D0D0D, CardOpacity); |
|
LSkCanvas.DrawRect(LabelRect, FPaint); |
|
|
|
// 4. CARD NAME TEXT |
|
TextRect := LabelRect; |
|
TextRect.Inflate(-6, 0); |
|
Canvas.Font.Style := []; |
|
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity); |
|
Canvas.Font.Size := 11; |
|
Canvas.FillText(TextRect, Card.Name + sLineBreak + Card.SetAndNum, True, CardOpacity, [], TTextAlign.Leading, TTextAlign.Center); |
|
|
|
// 5. HOVER/SELECTION EFFECTS |
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
if I = FHoveredCard then |
|
begin |
|
var GlowIntensity := 0.6 + (FHoverScaleProgress * 0.3); |
|
FBorderPaint.Color := MakeSkiaColor($FF4A90E2, GlowIntensity * CardOpacity); |
|
FBorderPaint.StrokeWidth := 2 + FHoverScaleProgress; |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FBorderPaint); |
|
end; |
|
{$ELSE} |
|
// Mobile: Ripple effect |
|
if (I = FRippleCardIndex) and (FRippleProgress < 1.0) then |
|
begin |
|
var RippleRadius := Min(FCardWidth, FCardHeight) * 0.7 * FRippleProgress; |
|
FPaint.Color := MakeSkiaColor($40FFFFFF, (1.0 - FRippleProgress) * CardOpacity); |
|
var RippleCenter := TPointF.Create(CardRect.CenterPoint.X, CardRect.CenterPoint.Y); |
|
LSkCanvas.DrawCircle(RippleCenter, RippleRadius, FPaint); |
|
end; |
|
|
|
// Subtle overlay on tap |
|
if I = FHoveredCard then |
|
begin |
|
FPaint.Color := MakeSkiaColor($20FFFFFF, CardOpacity); |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FPaint); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
except |
|
on E: Exception do |
|
inherited Paint; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; // Call inherited first |
|
|
|
if EventInfo.GestureID = igiLongTap then |
|
begin |
|
CardIndex := CardAtPoint(EventInfo.Location); |
|
if CardIndex >= 0 then |
|
begin |
|
FLongPressCard := CardIndex; |
|
FClickedCardUUID := FCards[CardIndex].UUID; |
|
|
|
// Visual feedback |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
FHoveredCard := CardIndex; |
|
InvalidateRect(GetCardRect(CardIndex)); |
|
{$ENDIF} |
|
|
|
if Assigned(FOnCardLongPress) then |
|
FOnCardLongPress(Self); |
|
|
|
Handled := True; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; |
|
if Button = TMouseButton.mbLeft then |
|
begin |
|
FMouseDownPos := PointF(X, Y); |
|
FMouseMoved := False; |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if CardIndex >= 0 then |
|
begin |
|
FClickedCardUUID := FCards[CardIndex].UUID; |
|
|
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
FHoveredCard := CardIndex; |
|
FRippleCardIndex := CardIndex; |
|
FRippleProgress := 0; |
|
TAnimator.AnimateFloat(Self, 'RippleProgress', 1.0, 0.4); |
|
InvalidateRect(GetCardRect(CardIndex)); |
|
{$ENDIF} |
|
|
|
if Assigned(FOnCardMouseDown) then |
|
FOnCardMouseDown(Self); |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
Dist: Single; |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
OldHovered: Integer; |
|
{$ENDIF} |
|
begin |
|
inherited; |
|
if Button = TMouseButton.mbLeft then |
|
begin |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
if FHoveredCard >= 0 then |
|
begin |
|
OldHovered := FHoveredCard; |
|
FHoveredCard := -1; |
|
InvalidateRect(GetCardRect(OldHovered)); |
|
end; |
|
{$ENDIF} |
|
|
|
Dist := FMouseDownPos.Distance(PointF(X, Y)); |
|
if Dist <= 15 then |
|
begin |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if (CardIndex >= 0) and (FCards[CardIndex].UUID = FClickedCardUUID) then |
|
if Assigned(FOnCardClick) then |
|
FOnCardClick(Self); |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseMove(Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; |
|
if not FMouseMoved and (FMouseDownPos.Distance(PointF(X, Y)) > 10) then |
|
FMouseMoved := True; |
|
|
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if CardIndex <> FHoveredCard then |
|
begin |
|
FHoveredCard := CardIndex; |
|
if CardIndex >= 0 then |
|
begin |
|
FHoverScaleProgress := 0; |
|
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress'); |
|
TAnimator.AnimateFloat(Self, 'HoverScaleProgress', 1.0, 0.2, TAnimationType.in, TInterpolationType.Circular); |
|
end; |
|
InvalidateRect(LocalRect); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
procedure TCustomCardGrid.Resize; |
|
begin |
|
inherited; |
|
CalculateLayout; |
|
CalculateVisibleIndices; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.UpdateCardImage(const ACardUUID: string; const AImage: ISkImage); |
|
var |
|
I: Integer; |
|
begin |
|
if AImage = nil then |
|
Exit; |
|
|
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
FCards[I].ImageSkia := AImage; // Old image released automatically via interface ref counting |
|
FCards[I].ImageLoading := False; |
|
|
|
if (I >= FVisibleIndexStart) and (I <= FVisibleIndexEnd) then |
|
begin |
|
if not FRepaintScheduled then |
|
begin |
|
FRepaintScheduled := True; |
|
TThread.ForceQueue(nil, |
|
procedure |
|
begin |
|
FRepaintScheduled := False; |
|
InvalidateRect(LocalRect); |
|
end); |
|
end; |
|
end; |
|
Break; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetCards(const ACards: TArray<TCard>); |
|
var |
|
I: Integer; |
|
begin |
|
ClearCards; |
|
for I := 0 to Length(ACards) - 1 do |
|
FCards.Add(TGridCardData.Create(ACards[I])); |
|
|
|
CalculateLayout; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
|
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for initially visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.AddCards(const ACards: TArray<TCard>); |
|
var |
|
I: Integer; |
|
begin |
|
for I := 0 to Length(ACards) - 1 do |
|
FCards.Add(TGridCardData.Create(ACards[I])); |
|
|
|
CalculateLayout; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
|
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for newly visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.ClearCards; |
|
begin |
|
FCards.Clear; // TObjectList handles all TGridCardData destruction |
|
FHoveredCard := -1; |
|
FLastCleanupTime := 0; |
|
CalculateLayout; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
function TCustomCardGrid.IsCardInRecentBuffer(const ACardUUID: string): Boolean; |
|
var |
|
I: Integer; |
|
BufferStart, BufferEnd: Integer; |
|
TotalCards: Integer; |
|
begin |
|
Result := False; |
|
TotalCards := FCards.Count; |
|
if TotalCards = 0 then |
|
Exit; |
|
|
|
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS); |
|
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS); |
|
|
|
for I := BufferStart to BufferEnd do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
Result := True; |
|
Break; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetScrollOffset(AOffset: Single); |
|
begin |
|
if Abs(FScrollOffset - AOffset) < 1.0 then |
|
Exit; |
|
|
|
if FScrollOffset <> AOffset then |
|
begin |
|
FScrollOffset := AOffset; |
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for newly visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
end; |
|
|
|
function TCustomCardGrid.GetCardUUIDAt(Index: Integer): string; |
|
begin |
|
Result := ''; |
|
if (Index >= 0) and (Index < FCards.Count) then |
|
Result := FCards[Index].UUID; |
|
end; |
|
|
|
function TCustomCardGrid.GetCardCount: Integer; |
|
begin |
|
Result := FCards.Count; |
|
end; |
|
|
|
function TCustomCardGrid.HasCardImage(const ACardUUID: string): Boolean; |
|
var |
|
I: Integer; |
|
begin |
|
Result := False; |
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
Result := Assigned(FCards[I].ImageSkia); |
|
Exit; |
|
end; |
|
end; |
|
end; |
|
|
|
end. |
|
private |
|
FCards: TObjectList<TGridCardData>; |
|
FCardWidth: Single; |
|
FCardHeight: Single; |
|
FMinCardWidth: Single; |
|
FColumnCount: Integer; |
|
FSpacing: Single; |
|
FHoveredCard: Integer; |
|
FScrollOffset: Single; |
|
FOnCardClick: TNotifyEvent; |
|
FOnCardMouseDown: TNotifyEvent; |
|
FClickedCardUUID: string; |
|
FPlaceholderBitmap: TBitmap; |
|
FVisibleIndexStart: Integer; |
|
FVisibleIndexEnd: Integer; |
|
FAnimProgress: Single; |
|
FMouseDownPos: TPointF; |
|
FMouseMoved: Boolean; |
|
FScreenScale: Single; |
|
FLastCleanupTime: TDateTime; |
|
FRepaintScheduled: Boolean; |
|
FOnCardLongPress: TNotifyEvent; |
|
FLongPressCard: Integer; |
|
FPriceLoadInProgress: Boolean; |
|
|
|
// Animation properties |
|
FHoverScaleProgress: Single; |
|
FShimmerProgress: Single; |
|
FRippleProgress: Single; |
|
FRippleCardIndex: Integer; |
|
|
|
procedure CalculateLayout; |
|
function GetCardRect(Index: Integer): TRectF; |
|
function CardAtPoint(const APoint: TPointF): Integer; |
|
procedure CreatePlaceholder; |
|
procedure CalculateVisibleIndices; |
|
procedure CheckForNewAnimations; |
|
procedure SetAnimProgress(const Value: Single); |
|
procedure SetHoverScaleProgress(const Value: Single); |
|
procedure SetShimmerProgress(const Value: Single); |
|
procedure SetRippleProgress(const Value: Single); |
|
procedure UpdateScreenScale; |
|
procedure CleanupOffScreenBitmaps; |
|
procedure StartShimmerAnimation; |
|
procedure LoadVisibleCardPrices; |
|
protected |
|
procedure Paint; override; |
|
procedure DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); override; |
|
procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; |
|
procedure MouseMove(Shift: TShiftState; X, Y: Single); override; |
|
procedure MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override; |
|
procedure Resize; override; |
|
public |
|
constructor Create(AOwner: TComponent); override; |
|
destructor Destroy; override; |
|
|
|
procedure SetCards(const ACards: TArray<TCard>); |
|
procedure AddCards(const ACards: TArray<TCard>); |
|
procedure ClearCards; |
|
function IsCardInRecentBuffer(const ACardUUID: string): Boolean; |
|
procedure SetScrollOffset(AOffset: Single); |
|
procedure UpdateCardImage(const ACardUUID: string; const AImage: ISkImage); |
|
function GetCardUUIDAt(Index: Integer): string; |
|
function GetCardCount: Integer; |
|
function HasCardImage(const ACardUUID: string): Boolean; |
|
procedure RefreshPrices; |
|
|
|
property OnCardLongPress: TNotifyEvent read FOnCardLongPress write FOnCardLongPress; |
|
property OnCardClick: TNotifyEvent read FOnCardClick write FOnCardClick; |
|
property OnCardMouseDown: TNotifyEvent read FOnCardMouseDown write FOnCardMouseDown; |
|
property ClickedCardUUID: string read FClickedCardUUID; |
|
property AnimProgress: Single read FAnimProgress write SetAnimProgress; |
|
property HoverScaleProgress: Single read FHoverScaleProgress write SetHoverScaleProgress; |
|
property ShimmerProgress: Single read FShimmerProgress write SetShimmerProgress; |
|
property RippleProgress: Single read FRippleProgress write SetRippleProgress; |
|
property MinCardWidth: Single read FMinCardWidth write FMinCardWidth; |
|
property ColumnCount: Integer read FColumnCount; |
|
property CardHeight: Single read FCardHeight; |
|
property Spacing: Single read FSpacing; |
|
end; |
|
|
|
implementation |
|
|
|
uses |
|
System.Threading, CardPriceManager; |
|
|
|
const |
|
CARD_IMAGE_RATIO = 1.3968; |
|
LABEL_HEIGHT = 42; |
|
ANIMATION_DURATION = 0.3; |
|
CLEANUP_INTERVAL_SECONDS = 2.0; |
|
OFFSCREEN_BUFFER_CARDS = 15; |
|
|
|
function MakeSkiaColor(const AColor: TAlphaColor; const AOpacity: Single): TAlphaColor; |
|
var |
|
ColorRec: TAlphaColorRec; |
|
begin |
|
ColorRec := TAlphaColorRec(AColor); |
|
ColorRec.A := Round(ColorRec.A * EnsureRange(AOpacity, 0.0, 1.0)); |
|
Result := ColorRec.Color; |
|
end; |
|
|
|
{ TGridCardData } |
|
|
|
constructor TGridCardData.Create(const ACard: TCard); |
|
begin |
|
inherited Create; |
|
UUID := ACard.UUID; |
|
Name := ACard.Name; |
|
IsOnlineOnly := ACard.IsOnlineOnly; |
|
SetCode := ACard.SetCode; |
|
ScryfallId := ACard.ScryfallId; |
|
ImageLoading := False; |
|
AnimationProgress := 0.0; |
|
FirstVisible := False; |
|
FHasPriceData := False; |
|
FPriceData := TCardPriceData.Empty; |
|
end; |
|
|
|
destructor TGridCardData.Destroy; |
|
begin |
|
FImageSkia := nil; |
|
inherited; |
|
end; |
|
|
|
procedure TGridCardData.UpdatePriceData(const APriceData: TCardPriceData); |
|
begin |
|
FPriceData := APriceData; |
|
FHasPriceData := True; |
|
end; |
|
|
|
function TGridCardData.GetDisplayPrice: string; |
|
begin |
|
if not FHasPriceData then |
|
Exit(''); |
|
|
|
// Try TCGPlayer first (most common for US users) |
|
if FPriceData.Paper.TCGPlayer.RetailNormal.Price > 0 then |
|
Result := Format('$%.2f', [FPriceData.Paper.TCGPlayer.RetailNormal.Price]) |
|
// Then Cardmarket (Europe) |
|
else if FPriceData.Paper.Cardmarket.RetailNormal.Price > 0 then |
|
begin |
|
if FPriceData.Paper.Cardmarket.Currency = curEUR then |
|
Result := Format('€%.2f', [FPriceData.Paper.Cardmarket.RetailNormal.Price]) |
|
else |
|
Result := Format('$%.2f', [FPriceData.Paper.Cardmarket.RetailNormal.Price]); |
|
end |
|
// Finally Card Kingdom |
|
else if FPriceData.Paper.CardKingdom.RetailNormal.Price > 0 then |
|
Result := Format('$%.2f', [FPriceData.Paper.CardKingdom.RetailNormal.Price]) |
|
else |
|
Result := ''; |
|
end; |
|
|
|
{ TCustomCardGrid } |
|
|
|
constructor TCustomCardGrid.Create(AOwner: TComponent); |
|
begin |
|
inherited; |
|
FCards := TObjectList<TGridCardData>.Create(True); // OwnsObjects = True |
|
Touch.InteractiveGestures := [TInteractiveGesture.LongTap]; |
|
FMinCardWidth := 100; |
|
FCardWidth := 150; |
|
FCardHeight := 210; |
|
FSpacing := 8; |
|
FHoveredCard := -1; |
|
FScrollOffset := 0; |
|
FColumnCount := 3; |
|
FVisibleIndexStart := 0; |
|
FVisibleIndexEnd := 0; |
|
FAnimProgress := 0; |
|
FMouseDownPos := TPointF.Zero; |
|
FMouseMoved := False; |
|
FLastCleanupTime := 0; |
|
HitTest := True; |
|
FRepaintScheduled := False; |
|
FLongPressCard := -1; |
|
FPriceLoadInProgress := False; |
|
|
|
// Initialize animation properties |
|
FHoverScaleProgress := 0; |
|
FShimmerProgress := 0; |
|
FRippleProgress := 0; |
|
FRippleCardIndex := -1; |
|
|
|
UpdateScreenScale; |
|
CreatePlaceholder; |
|
StartShimmerAnimation; |
|
end; |
|
|
|
destructor TCustomCardGrid.Destroy; |
|
begin |
|
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'RippleProgress'); |
|
TAnimator.StopPropertyAnimation(Self, 'AnimProgress'); |
|
|
|
FCards.Free; // TObjectList handles all TGridCardData destruction |
|
|
|
if Assigned(FPlaceholderBitmap) then |
|
FPlaceholderBitmap.Free; |
|
|
|
inherited; |
|
end; |
|
|
|
procedure TCustomCardGrid.UpdateScreenScale; |
|
var |
|
ScreenService: IFMXScreenService; |
|
begin |
|
FScreenScale := 1.0; |
|
if TPlatformServices.Current.SupportsPlatformService(IFMXScreenService, ScreenService) then |
|
FScreenScale := ScreenService.GetScreenScale; |
|
|
|
{$IFDEF ANDROID} |
|
if FScreenScale > 2.0 then |
|
FScreenScale := 2.0; |
|
{$ENDIF} |
|
{$IFDEF IOS} |
|
if FScreenScale > 2.0 then |
|
FScreenScale := 2.0; |
|
{$ENDIF} |
|
end; |
|
|
|
procedure TCustomCardGrid.CreatePlaceholder; |
|
begin |
|
FPlaceholderBitmap := TBitmap.Create(Round(150 * FScreenScale), Round((210 - LABEL_HEIGHT) * FScreenScale)); |
|
FPlaceholderBitmap.Clear(TAlphaColors.Dimgray); |
|
|
|
if FPlaceholderBitmap.Canvas.BeginScene then |
|
try |
|
FPlaceholderBitmap.Canvas.Fill.Color := TAlphaColors.White; |
|
FPlaceholderBitmap.Canvas.Font.Size := 14 * FScreenScale; |
|
FPlaceholderBitmap.Canvas.FillText(RectF(0, 0, FPlaceholderBitmap.Width, FPlaceholderBitmap.Height), 'Loading...', False, 1.0, [], TTextAlign.Center, TTextAlign.Center); |
|
finally |
|
FPlaceholderBitmap.Canvas.EndScene; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.CleanupOffScreenBitmaps; |
|
var |
|
I: Integer; |
|
CurrentTime: TDateTime; |
|
BufferStart, BufferEnd: Integer; |
|
TotalCards: Integer; |
|
begin |
|
CurrentTime := Now; |
|
|
|
if (CurrentTime - FLastCleanupTime) < (CLEANUP_INTERVAL_SECONDS / 86400.0) then |
|
Exit; |
|
|
|
FLastCleanupTime := CurrentTime; |
|
|
|
TotalCards := FCards.Count; |
|
if TotalCards = 0 then |
|
Exit; |
|
|
|
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS); |
|
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS); |
|
|
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if (I < BufferStart) or (I > BufferEnd) then |
|
begin |
|
if Assigned(FCards[I].ImageSkia) then |
|
FCards[I].ImageSkia := nil; // Direct mutation - no copy needed |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.LoadVisibleCardPrices; |
|
var |
|
I: Integer; |
|
Card: TGridCardData; |
|
begin |
|
if FPriceLoadInProgress then Exit; |
|
if not PriceManager.HasPriceData then Exit; |
|
|
|
FPriceLoadInProgress := True; |
|
|
|
TTask.Run(procedure |
|
var |
|
Prices: TCardPriceData; |
|
NeedsRepaint: Boolean; |
|
CardIndex: Integer; |
|
CardUUID: string; |
|
begin |
|
NeedsRepaint := False; |
|
|
|
// Capture the range we're working with |
|
for CardIndex := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if CardIndex >= FCards.Count then Break; |
|
|
|
// Thread-safe read of card data |
|
TThread.Synchronize(nil, procedure |
|
begin |
|
if CardIndex < FCards.Count then |
|
begin |
|
Card := FCards[CardIndex]; |
|
CardUUID := Card.UUID; |
|
end |
|
else |
|
CardUUID := ''; |
|
end); |
|
|
|
if CardUUID = '' then Continue; |
|
|
|
// Only fetch if we don't have it cached |
|
TThread.Synchronize(nil, procedure |
|
begin |
|
if CardIndex < FCards.Count then |
|
Card := FCards[CardIndex] |
|
else |
|
Card := nil; |
|
end); |
|
|
|
if Assigned(Card) and not Card.HasPriceData then |
|
begin |
|
if PriceManager.GetCardPrices(CardUUID, Prices) then |
|
begin |
|
TThread.Synchronize(nil, procedure |
|
begin |
|
if CardIndex < FCards.Count then |
|
begin |
|
FCards[CardIndex].UpdatePriceData(Prices); |
|
NeedsRepaint := True; |
|
end; |
|
end); |
|
end; |
|
end; |
|
end; |
|
|
|
TThread.Synchronize(nil, procedure |
|
begin |
|
FPriceLoadInProgress := False; |
|
if NeedsRepaint then |
|
InvalidateRect(LocalRect); |
|
end); |
|
end); |
|
end; |
|
|
|
procedure TCustomCardGrid.RefreshPrices; |
|
var |
|
I: Integer; |
|
begin |
|
// Clear cached prices and reload |
|
for I := 0 to FCards.Count - 1 do |
|
FCards[I].FHasPriceData := False; |
|
|
|
LoadVisibleCardPrices; |
|
end; |
|
|
|
procedure TCustomCardGrid.CalculateLayout; |
|
var |
|
RowCount: Integer; |
|
NewHeight: Single; |
|
ParentWidth: Single; |
|
ImageAreaHeight: Single; |
|
begin |
|
if FCards.Count = 0 then |
|
begin |
|
Width := 300; |
|
Height := 100; |
|
Exit; |
|
end; |
|
|
|
if Assigned(Parent) and (Parent is TControl) then |
|
ParentWidth := (Parent as TControl).Width |
|
else |
|
ParentWidth := Width; |
|
|
|
ParentWidth := ParentWidth - 20; |
|
if ParentWidth <= 0 then |
|
ParentWidth := 300; |
|
|
|
FColumnCount := Floor((ParentWidth - FSpacing) / (FMinCardWidth + FSpacing)); |
|
if FColumnCount < 1 then |
|
FColumnCount := 1; |
|
|
|
FCardWidth := (ParentWidth - (FSpacing * (FColumnCount + 1))) / FColumnCount; |
|
|
|
ImageAreaHeight := FCardWidth * CARD_IMAGE_RATIO; |
|
FCardHeight := ImageAreaHeight + LABEL_HEIGHT; |
|
|
|
RowCount := Ceil(FCards.Count / FColumnCount); |
|
NewHeight := (RowCount * (FCardHeight + FSpacing)) + FSpacing; |
|
|
|
if (Width <> ParentWidth + 20) or (Height <> NewHeight) then |
|
begin |
|
Width := ParentWidth + 20; |
|
Height := NewHeight; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.CalculateVisibleIndices; |
|
var |
|
ViewportTop, ViewportBottom: Single; |
|
FirstRow, LastRow: Integer; |
|
CalcEnd: Integer; |
|
begin |
|
if Assigned(Parent) and (Parent is TControl) then |
|
ViewportBottom := FScrollOffset + (Parent as TControl).Height |
|
else |
|
ViewportBottom := FScrollOffset + 600; |
|
|
|
ViewportTop := FScrollOffset; |
|
|
|
FirstRow := Max(0, Floor((ViewportTop - FSpacing) / (FCardHeight + FSpacing)) - 1); |
|
LastRow := Min(Ceil(FCards.Count / FColumnCount) - 1, Ceil((ViewportBottom + FSpacing) / (FCardHeight + FSpacing)) + 1); |
|
|
|
FVisibleIndexStart := Max(0, FirstRow * FColumnCount); |
|
|
|
CalcEnd := (LastRow + 1) * FColumnCount - 1; |
|
FVisibleIndexEnd := Min(FCards.Count - 1, CalcEnd); |
|
|
|
if LastRow >= (Ceil(FCards.Count / FColumnCount) - 2) then |
|
FVisibleIndexEnd := FCards.Count - 1; |
|
end; |
|
|
|
procedure TCustomCardGrid.CheckForNewAnimations; |
|
var |
|
I: Integer; |
|
HasNewCards: Boolean; |
|
begin |
|
HasNewCards := False; |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I < FCards.Count then |
|
begin |
|
if not FCards[I].FirstVisible then |
|
begin |
|
FCards[I].FirstVisible := True; |
|
FCards[I].AnimationProgress := 0.0; |
|
HasNewCards := True; |
|
end; |
|
end; |
|
end; |
|
|
|
if HasNewCards then |
|
begin |
|
FAnimProgress := 0; |
|
TAnimator.StopPropertyAnimation(Self, 'AnimProgress'); |
|
TAnimator.AnimateFloat(Self, 'AnimProgress', 1.0, ANIMATION_DURATION, TAnimationType.in, TInterpolationType.Back); |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetAnimProgress(const Value: Single); |
|
var |
|
I: Integer; |
|
HasChanges: Boolean; |
|
begin |
|
if Abs(FAnimProgress - Value) < 0.02 then |
|
Exit; |
|
|
|
FAnimProgress := Value; |
|
HasChanges := False; |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I < FCards.Count then |
|
begin |
|
if FCards[I].FirstVisible and (FCards[I].AnimationProgress < 0.99) then |
|
begin |
|
FCards[I].AnimationProgress := Value; |
|
HasChanges := True; |
|
end; |
|
end; |
|
end; |
|
|
|
if HasChanges then |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetHoverScaleProgress(const Value: Single); |
|
begin |
|
if Abs(FHoverScaleProgress - Value) < 0.01 then |
|
Exit; |
|
FHoverScaleProgress := Value; |
|
if FHoveredCard >= 0 then |
|
InvalidateRect(GetCardRect(FHoveredCard)); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetShimmerProgress(const Value: Single); |
|
begin |
|
if FShimmerProgress = Value then |
|
Exit; |
|
FShimmerProgress := Value; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.SetRippleProgress(const Value: Single); |
|
begin |
|
FRippleProgress := Value; |
|
if (FRippleCardIndex >= 0) and (FRippleCardIndex < FCards.Count) then |
|
InvalidateRect(GetCardRect(FRippleCardIndex)); |
|
end; |
|
|
|
procedure TCustomCardGrid.StartShimmerAnimation; |
|
begin |
|
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress'); |
|
TAnimator.AnimateFloat(Self, 'ShimmerProgress', 1.0, 1.5, TAnimationType.in, TInterpolationType.Linear); |
|
end; |
|
|
|
function TCustomCardGrid.GetCardRect(Index: Integer): TRectF; |
|
var |
|
Col, Row: Integer; |
|
X, Y: Single; |
|
ScaleFactor: Single; |
|
begin |
|
Col := Index mod FColumnCount; |
|
Row := Index div FColumnCount; |
|
|
|
X := FSpacing + Col * (FCardWidth + FSpacing); |
|
Y := FSpacing + Row * (FCardHeight + FSpacing); |
|
|
|
Result := RectF(X, Y, X + FCardWidth, Y + FCardHeight); |
|
|
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
if Index = FHoveredCard then |
|
begin |
|
ScaleFactor := 1.0 + (FHoverScaleProgress * 0.05); |
|
Result.Inflate((FCardWidth * (ScaleFactor - 1)) / 2, (FCardHeight * (ScaleFactor - 1)) / 2); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
function TCustomCardGrid.CardAtPoint(const APoint: TPointF): Integer; |
|
var |
|
Col, Row: Integer; |
|
begin |
|
Result := -1; |
|
if (APoint.X < FSpacing) or (APoint.X > Width - FSpacing) then |
|
Exit; |
|
|
|
Row := Floor((APoint.Y - FSpacing) / (FCardHeight + FSpacing)); |
|
Col := Floor((APoint.X - FSpacing) / (FCardWidth + FSpacing)); |
|
|
|
if (Row >= 0) and (Col >= 0) and (Col < FColumnCount) then |
|
begin |
|
Result := Row * FColumnCount + Col; |
|
if Result >= FCards.Count then |
|
Result := -1; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.Paint; |
|
var |
|
I: Integer; |
|
CardRect, ImageRect, TextRect, LabelRect, ChipRect: TRectF; |
|
Card: TGridCardData; |
|
CardOpacity: Single; |
|
LSkCanvas: ISkCanvas; |
|
LPaint, LBorderPaint: ISkPaint; |
|
PriceText: string; |
|
begin |
|
inherited; |
|
|
|
if FCards.Count = 0 then |
|
Exit; |
|
|
|
CleanupOffScreenBitmaps; |
|
|
|
try |
|
LSkCanvas := TSkCanvasCustom(Canvas).Canvas; |
|
LSkCanvas.Clear(TAlphaColors.Black); |
|
|
|
LPaint := TSkPaint.Create; |
|
LPaint.AntiAlias := True; |
|
|
|
LBorderPaint := TSkPaint.Create; |
|
LBorderPaint.Style := TSkPaintStyle.Stroke; |
|
LBorderPaint.StrokeWidth := 2; |
|
LBorderPaint.AntiAlias := True; |
|
|
|
for I := FVisibleIndexStart to FVisibleIndexEnd do |
|
begin |
|
if I >= FCards.Count then |
|
Break; |
|
|
|
Card := FCards[I]; |
|
CardRect := GetCardRect(I); |
|
|
|
CardOpacity := Card.AnimationProgress; |
|
if Card.AnimationProgress >= 0.99 then |
|
CardOpacity := 1.0; |
|
|
|
// 1. CARD BACKGROUND |
|
LPaint.Color := MakeSkiaColor($FF1A1A1A, CardOpacity); |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint); |
|
|
|
// 2. CARD IMAGE AREA |
|
ImageRect := RectF(CardRect.Left, CardRect.Top, CardRect.Right, CardRect.Bottom - LABEL_HEIGHT); |
|
|
|
if Assigned(Card.ImageSkia) then |
|
begin |
|
LPaint.AlphaF := CardOpacity; |
|
LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium); |
|
LPaint.AlphaF := 1.0; |
|
|
|
// Draw price chip if available |
|
if Card.HasPriceData and Card.IsOnlineOnly = False then |
|
begin |
|
PriceText := Card.GetDisplayPrice; |
|
if PriceText <> '' then |
|
begin |
|
ChipRect := TRectF.Create( |
|
ImageRect.Right - 60, |
|
ImageRect.Bottom - 28, |
|
ImageRect.Right - 8, |
|
ImageRect.Bottom - 8 |
|
); |
|
|
|
// Chip background (semi-transparent dark) |
|
LPaint.Shader := nil; |
|
LPaint.Color := MakeSkiaColor($DD000000, CardOpacity); // 87% opacity black |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(ChipRect, 10, 10), LPaint); |
|
|
|
// Price text |
|
ChipRect.Inflate(-4, 0); |
|
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity); |
|
Canvas.Font.Size := 9; |
|
Canvas.Font.Style := [TFontStyle.fsBold]; |
|
Canvas.FillText(ChipRect, PriceText, False, CardOpacity, |
|
[], TTextAlign.Center, TTextAlign.Center); |
|
end; |
|
end; |
|
end |
|
else |
|
begin |
|
// ANIMATED SHIMMER for loading placeholder |
|
LPaint.Shader := TSkShader.MakeGradientLinear( |
|
TPointF.Create(ImageRect.Left, ImageRect.Top), |
|
TPointF.Create(ImageRect.Right, ImageRect.Top), |
|
TArray<TAlphaColor>.Create( |
|
MakeSkiaColor($FF2A2A2A, CardOpacity), |
|
MakeSkiaColor($FF3A3A3A, CardOpacity), |
|
MakeSkiaColor($FF2A2A2A, CardOpacity) |
|
), |
|
TArray<Single>.Create( |
|
Max(0.0, FShimmerProgress - 0.3), |
|
FShimmerProgress, |
|
Min(1.0, FShimmerProgress + 0.3) |
|
), |
|
TSkTileMode.Clamp |
|
); |
|
LSkCanvas.DrawRect(ImageRect, LPaint); |
|
LPaint.Shader := nil; |
|
end; |
|
|
|
// 3. LABEL BACKGROUND |
|
LabelRect := RectF(CardRect.Left, CardRect.Bottom - LABEL_HEIGHT, CardRect.Right, CardRect.Bottom); |
|
LPaint.Color := MakeSkiaColor($FF0D0D0D, CardOpacity); |
|
LSkCanvas.DrawRect(LabelRect, LPaint); |
|
|
|
// 4. CARD NAME TEXT |
|
TextRect := LabelRect; |
|
TextRect.Inflate(-6, 0); |
|
Canvas.Font.Style := []; |
|
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity); |
|
Canvas.Font.Size := 11; |
|
Canvas.FillText(TextRect, Card.Name, True, CardOpacity, [], TTextAlign.Leading, TTextAlign.Center); |
|
|
|
// 5. HOVER/SELECTION EFFECTS |
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
if I = FHoveredCard then |
|
begin |
|
var GlowIntensity := 0.6 + (FHoverScaleProgress * 0.3); |
|
LBorderPaint.Color := MakeSkiaColor($FF4A90E2, GlowIntensity * CardOpacity); |
|
LBorderPaint.StrokeWidth := 2 + FHoverScaleProgress; |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LBorderPaint); |
|
end; |
|
{$ELSE} |
|
// Mobile: Ripple effect |
|
if (I = FRippleCardIndex) and (FRippleProgress < 1.0) then |
|
begin |
|
var RippleRadius := Min(FCardWidth, FCardHeight) * 0.7 * FRippleProgress; |
|
LPaint.Color := MakeSkiaColor($40FFFFFF, (1.0 - FRippleProgress) * CardOpacity); |
|
var RippleCenter := TPointF.Create(CardRect.CenterPoint.X, CardRect.CenterPoint.Y); |
|
LSkCanvas.DrawCircle(RippleCenter, RippleRadius, LPaint); |
|
end; |
|
|
|
// Subtle overlay on tap |
|
if I = FHoveredCard then |
|
begin |
|
LPaint.Color := MakeSkiaColor($20FFFFFF, CardOpacity); |
|
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
except |
|
on E: Exception do |
|
inherited Paint; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; // Call inherited first |
|
|
|
if EventInfo.GestureID = igiLongTap then |
|
begin |
|
CardIndex := CardAtPoint(EventInfo.Location); |
|
if CardIndex >= 0 then |
|
begin |
|
FLongPressCard := CardIndex; |
|
FClickedCardUUID := FCards[CardIndex].UUID; |
|
|
|
// Visual feedback |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
FHoveredCard := CardIndex; |
|
InvalidateRect(GetCardRect(CardIndex)); |
|
{$ENDIF} |
|
|
|
if Assigned(FOnCardLongPress) then |
|
FOnCardLongPress(Self); |
|
|
|
Handled := True; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; |
|
if Button = TMouseButton.mbLeft then |
|
begin |
|
FMouseDownPos := PointF(X, Y); |
|
FMouseMoved := False; |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if CardIndex >= 0 then |
|
begin |
|
FClickedCardUUID := FCards[CardIndex].UUID; |
|
|
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
FHoveredCard := CardIndex; |
|
FRippleCardIndex := CardIndex; |
|
FRippleProgress := 0; |
|
TAnimator.AnimateFloat(Self, 'RippleProgress', 1.0, 0.4); |
|
InvalidateRect(GetCardRect(CardIndex)); |
|
{$ENDIF} |
|
|
|
if Assigned(FOnCardMouseDown) then |
|
FOnCardMouseDown(Self); |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
Dist: Single; |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
OldHovered: Integer; |
|
{$ENDIF} |
|
begin |
|
inherited; |
|
if Button = TMouseButton.mbLeft then |
|
begin |
|
{$IF DEFINED(ANDROID) OR DEFINED(IOS)} |
|
if FHoveredCard >= 0 then |
|
begin |
|
OldHovered := FHoveredCard; |
|
FHoveredCard := -1; |
|
InvalidateRect(GetCardRect(OldHovered)); |
|
end; |
|
{$ENDIF} |
|
|
|
Dist := FMouseDownPos.Distance(PointF(X, Y)); |
|
if Dist <= 15 then |
|
begin |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if (CardIndex >= 0) and (FCards[CardIndex].UUID = FClickedCardUUID) then |
|
if Assigned(FOnCardClick) then |
|
FOnCardClick(Self); |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.MouseMove(Shift: TShiftState; X, Y: Single); |
|
var |
|
CardIndex: Integer; |
|
begin |
|
inherited; |
|
if not FMouseMoved and (FMouseDownPos.Distance(PointF(X, Y)) > 10) then |
|
FMouseMoved := True; |
|
|
|
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))} |
|
CardIndex := CardAtPoint(PointF(X, Y)); |
|
if CardIndex <> FHoveredCard then |
|
begin |
|
FHoveredCard := CardIndex; |
|
if CardIndex >= 0 then |
|
begin |
|
FHoverScaleProgress := 0; |
|
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress'); |
|
TAnimator.AnimateFloat(Self, 'HoverScaleProgress', 1.0, 0.2, TAnimationType.in, TInterpolationType.Circular); |
|
end; |
|
InvalidateRect(LocalRect); |
|
end; |
|
{$ENDIF} |
|
end; |
|
|
|
procedure TCustomCardGrid.Resize; |
|
begin |
|
inherited; |
|
CalculateLayout; |
|
CalculateVisibleIndices; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.UpdateCardImage(const ACardUUID: string; const AImage: ISkImage); |
|
var |
|
I: Integer; |
|
begin |
|
if AImage = nil then |
|
Exit; |
|
|
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
FCards[I].ImageSkia := AImage; // Old image released automatically via interface ref counting |
|
FCards[I].ImageLoading := False; |
|
|
|
if (I >= FVisibleIndexStart) and (I <= FVisibleIndexEnd) then |
|
begin |
|
if not FRepaintScheduled then |
|
begin |
|
FRepaintScheduled := True; |
|
TThread.ForceQueue(nil, |
|
procedure |
|
begin |
|
FRepaintScheduled := False; |
|
InvalidateRect(LocalRect); |
|
end); |
|
end; |
|
end; |
|
Break; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetCards(const ACards: TArray<TCard>); |
|
var |
|
I: Integer; |
|
begin |
|
ClearCards; |
|
for I := 0 to Length(ACards) - 1 do |
|
FCards.Add(TGridCardData.Create(ACards[I])); |
|
|
|
CalculateLayout; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
|
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for initially visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.AddCards(const ACards: TArray<TCard>); |
|
var |
|
I: Integer; |
|
begin |
|
for I := 0 to Length(ACards) - 1 do |
|
FCards.Add(TGridCardData.Create(ACards[I])); |
|
|
|
CalculateLayout; |
|
if Assigned(Parent) and (Parent is TScrollBox) then |
|
TScrollBox(Parent).RealignContent; |
|
|
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for newly visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
procedure TCustomCardGrid.ClearCards; |
|
begin |
|
FCards.Clear; // TObjectList handles all TGridCardData destruction |
|
FHoveredCard := -1; |
|
FLastCleanupTime := 0; |
|
CalculateLayout; |
|
InvalidateRect(LocalRect); |
|
end; |
|
|
|
function TCustomCardGrid.IsCardInRecentBuffer(const ACardUUID: string): Boolean; |
|
var |
|
I: Integer; |
|
BufferStart, BufferEnd: Integer; |
|
TotalCards: Integer; |
|
begin |
|
Result := False; |
|
TotalCards := FCards.Count; |
|
if TotalCards = 0 then |
|
Exit; |
|
|
|
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS); |
|
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS); |
|
|
|
for I := BufferStart to BufferEnd do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
Result := True; |
|
Break; |
|
end; |
|
end; |
|
end; |
|
|
|
procedure TCustomCardGrid.SetScrollOffset(AOffset: Single); |
|
begin |
|
if Abs(FScrollOffset - AOffset) < 1.0 then |
|
Exit; |
|
|
|
if FScrollOffset <> AOffset then |
|
begin |
|
FScrollOffset := AOffset; |
|
CalculateVisibleIndices; |
|
CheckForNewAnimations; |
|
LoadVisibleCardPrices; // Load prices for newly visible cards |
|
InvalidateRect(LocalRect); |
|
end; |
|
end; |
|
|
|
function TCustomCardGrid.GetCardUUIDAt(Index: Integer): string; |
|
begin |
|
Result := ''; |
|
if (Index >= 0) and (Index < FCards.Count) then |
|
Result := FCards[Index].UUID; |
|
end; |
|
|
|
function TCustomCardGrid.GetCardCount: Integer; |
|
begin |
|
Result := FCards.Count; |
|
end; |
|
|
|
function TCustomCardGrid.HasCardImage(const ACardUUID: string): Boolean; |
|
var |
|
I: Integer; |
|
begin |
|
Result := False; |
|
for I := 0 to FCards.Count - 1 do |
|
begin |
|
if FCards[I].UUID = ACardUUID then |
|
begin |
|
Result := Assigned(FCards[I].ImageSkia); |
|
Exit; |
|
end; |
|
end; |
|
end; |
|
|
|
end. |