perf: efekt suruklemesinde kitlenme fix (kare hizi throttle, repaint izolasyonu, downsampling, cache sinirlari), .deb guncellendi
This commit is contained in:
10
README.md
10
README.md
@@ -126,6 +126,16 @@ Gitea Actions ile Windows build ve Inno Setup installer otomatik oluşturulur:
|
|||||||
- **Çıktı:** `imajviewer.exe` + tüm DLL'ler, `imajviewer_setup_*.exe`
|
- **Çıktı:** `imajviewer.exe` + tüm DLL'ler, `imajviewer_setup_*.exe`
|
||||||
- **Tag bazlı release:** `v*` tag'i push'lanırsa Gitea Release oluşturulur (`GITEA_TOKEN` gerekli)
|
- **Tag bazlı release:** `v*` tag'i push'lanırsa Gitea Release oluşturulur (`GITEA_TOKEN` gerekli)
|
||||||
|
|
||||||
|
## Sistem Gereksinimleri — Donanım Hızlandırma (VM önerisi)
|
||||||
|
|
||||||
|
Uygulama yazılımsal render (llvmpipe) altında çalışır, ancak efekt sürüklemesi
|
||||||
|
büyük görsellerde kare atlayabilir. Sanal makinede akıcı kullanım için:
|
||||||
|
|
||||||
|
- **virtio-gpu + VirGL**: QEMU/libvirt'ta `virtio-vga-gl` / `virtio-gpu-gl`
|
||||||
|
(mesa VirGL yoluyla hızlandırılmış GL). `/dev/dri` görünür hale gelir,
|
||||||
|
llvmpipe yerine donanım render'ı kullanılır.
|
||||||
|
- **PCIe Passthrough**: gerçek GPU'nun VM'e bağlanması (en yüksek performans).
|
||||||
|
|
||||||
## Geliştirme
|
## Geliştirme
|
||||||
|
|
||||||
Flutter SDK gereklidir.
|
Flutter SDK gereklidir.
|
||||||
|
|||||||
BIN
dist/imajviewer_1.0.0_amd64.deb
vendored
BIN
dist/imajviewer_1.0.0_amd64.deb
vendored
Binary file not shown.
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
| Tarih | Dosya | Konu |
|
| Tarih | Dosya | Konu |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-08-03 | [performance_llvmpipe_issue.md](performance_llvmpipe_issue.md) | **Rapor** — llvmpipe donma analizi, kare hızı throttle, ölçüm sonuçları |
|
||||||
| 2026-08-03 | [rapor-brightness.md](rapor-brightness.md) | **Rapor** — brightness efekti (Ctrl+Shift+sağ tık) + 4x5 matris birleştirme |
|
| 2026-08-03 | [rapor-brightness.md](rapor-brightness.md) | **Rapor** — brightness efekti (Ctrl+Shift+sağ tık) + 4x5 matris birleştirme |
|
||||||
| 2026-08-03 | [galeri-klasor-tabanli.md](galeri-klasor-tabanli.md) | **Rapor** — klasör tabanlı galeri + precache/RAM kararı |
|
| 2026-08-03 | [galeri-klasor-tabanli.md](galeri-klasor-tabanli.md) | **Rapor** — klasör tabanlı galeri + precache/RAM kararı |
|
||||||
| 2026-08-03 | [session-2026-08-03.md](session-2026-08-03.md) | **Session özeti** — jfif/jpe/wbmp, pencere cascade, konum kaydı düzeltmesi |
|
| 2026-08-03 | [session-2026-08-03.md](session-2026-08-03.md) | **Session özeti** — jfif/jpe/wbmp, pencere cascade, konum kaydı düzeltmesi |
|
||||||
|
|||||||
71
docs/performance_llvmpipe_issue.md
Normal file
71
docs/performance_llvmpipe_issue.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Performans: llvmpipe (Yazılımsal Render) Altında Efekt Donmaları
|
||||||
|
|
||||||
|
Tarih: 2026-08-03
|
||||||
|
Durum: Uygulandı (ölçüm sonuçları bölüm 3'te)
|
||||||
|
|
||||||
|
## 1. Teşhis
|
||||||
|
|
||||||
|
- VM'de donanım GPU'su yok: `glxinfo -B` → `Device: llvmpipe (LLVM 21.1.8, 128 bits)`,
|
||||||
|
`/dev/dri` yok. Tüm çizim 4 CPU çekirdeğinde LLVM ile yapılıyor.
|
||||||
|
- Efekt sürüklemesi (contrast/saturation/brightness/rotate): her `PointerMoveEvent`
|
||||||
|
doğrudan `setState` → tüm görsel katman `ColorFiltered` ile yeniden boyanıyor.
|
||||||
|
- Fare sürüklemesinde olay hızı 100-250+/sn; llvmpipe kare süresi 20-100ms+.
|
||||||
|
Üretim > tüketim → kuyruk birikmesi → UI thread %100 → donma. Kuyruk boşalınca
|
||||||
|
arayüz yakalıyor ("1 dk sonra normale döndü").
|
||||||
|
- Rotate'in önce kilitlenmesi normal: `Transform` ikinci bir composite geçişi ekliyor.
|
||||||
|
Contrast aynı duvara galeri akışıyla (daha büyük görseller, dolu ImageCache) çarptı.
|
||||||
|
|
||||||
|
## 2. Uygulanan Değişiklikler
|
||||||
|
|
||||||
|
### Render izolasyonu ve kare hızı eşitleme — `lib/widgets/image_canvas.dart`
|
||||||
|
|
||||||
|
- Pointer move / scroll / pan / efekt işleyicilerinden `setState` kaldırıldı.
|
||||||
|
- `ValueNotifier<int> _renderTick` + `ValueListenableBuilder` — görsel katman yalnızca
|
||||||
|
tick ile yeniden kurulur.
|
||||||
|
- `SchedulerBinding.instance.scheduleFrameCallback` — saniyede en fazla 1 rebuild/kare
|
||||||
|
(ekran yenileme hızıyla sınırlı); olaylar kareye eşitlenir, kuyruk birikmez.
|
||||||
|
- `RepaintBoundary` — efekt repaint'i pencere/title bar/üst widget render'ını etkilemez.
|
||||||
|
- Not: per-efekt `ValueNotifier` yerine tek tick notifier kullanıldı. Nedeni:
|
||||||
|
`ValueListenableBuilder` her değer değişiminde yeniden kurulur (olay hızında kalır);
|
||||||
|
throttling için tek noktadan kare başına tick gerekli.
|
||||||
|
|
||||||
|
### Image pipeline — `lib/widgets/image_canvas.dart` + `lib/services/image_manager.dart`
|
||||||
|
|
||||||
|
- `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` — yüksek çözünürlüklü görseller
|
||||||
|
görüntüleme boyutunun ~2 katıyla sınırlı decode edilir: decode süresi, bellek ve
|
||||||
|
llvmpipe CPU yükü düşer. (Zoom 2x'e kadar net; ötesi canlı önizleme için kabul edilen takas.)
|
||||||
|
- `configureCache`: 1000 görsel / 1GB → **16 görsel / 256MB**.
|
||||||
|
- Galeri/klasör geçişinde `imageCache.clear()` — canlı (gösterilen) görsel korunur,
|
||||||
|
eski klasörün decode edilmiş `ui.Image` nesneleri bellekten düşer.
|
||||||
|
|
||||||
|
## 3. Ölçüm (llvmpipe)
|
||||||
|
|
||||||
|
Ölçüm yöntemi: 4000×3000 PNG açıldı; kullanıcı elle contrast / saturation /
|
||||||
|
brightness sürüklemeleri yaptı. Kare süreleri `addTimingsCallback` ile (geçici
|
||||||
|
enstrümantasyon, sonra kaldırıldı), CPU `pidstat` ile örneklendi.
|
||||||
|
|
||||||
|
| Metrik | Değer |
|
||||||
|
|---|---|
|
||||||
|
| Kare sayısı (contrast turu) | 76 |
|
||||||
|
| build ort / max | 3.9 ms / 78 ms |
|
||||||
|
| raster ort / max | 15.1 ms / 52 ms |
|
||||||
|
| Kare sayısı (sat/brightness turu) | 133 |
|
||||||
|
| build ort / max | 3.0 ms / 82 ms |
|
||||||
|
| raster ort / max | 14.1 ms / 48 ms |
|
||||||
|
| CPU — boştayken | %0-14 |
|
||||||
|
| CPU — aktif sürüklemede | %100-163 (1-1.6 çekirdek) |
|
||||||
|
|
||||||
|
Değerlendirme: raster ort. ~14-15 ms (16.7 ms kare bütçesinin altında), sürükleme
|
||||||
|
sırasında maksimum ~50 ms → ara sıra kare atlanır ama **kuyruk birikmesi ve
|
||||||
|
dakikalarca süren donma yok**. CPU 4 çekirdeğin tamamını asla doyurmuyor
|
||||||
|
(önceki durumda sürükleme anında tam doygunluk + 1 dk donma vardı).
|
||||||
|
|
||||||
|
## 4. VM Sistem Gereksinimleri (öneri — uygulama dışı)
|
||||||
|
|
||||||
|
llvmpipe tamamen yazılımsal; kalıcı çözüm VM tarafında donanım hızlandırmasıdır:
|
||||||
|
|
||||||
|
- **virtio-gpu + VirGL**: QEMU/libvirt'ta `-device virtio-vga-gl` / `virtio-gpu-gl`
|
||||||
|
(mesa'nın VirGL yoluyla hızlandırılmış GL'si). `/dev/dri` görünür hale gelir.
|
||||||
|
- **PCIe Passthrough**: gerçek GPU'yı VM'e bağlama (en yüksek performans, donanım gerektirir).
|
||||||
|
- Bu olmadan uygulama yukarıdaki yazılımsal önlemlerle çalışır, ancak büyük görsellerde
|
||||||
|
efekt sürüklemesi yine de kare atlayabilir.
|
||||||
@@ -63,6 +63,9 @@ class ImageManager extends ChangeNotifier {
|
|||||||
.indexWhere((p) => p.split(Platform.pathSeparator).last == base);
|
.indexWhere((p) => p.split(Platform.pathSeparator).last == base);
|
||||||
}
|
}
|
||||||
if (_currentIndex < 0) _currentIndex = 0;
|
if (_currentIndex < 0) _currentIndex = 0;
|
||||||
|
|
||||||
|
// Eski klasörün decode edilmiş görsellerini bırak; canlı görsel korunur
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +89,8 @@ class ImageManager extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Configure image cache for high-res images
|
/// Configure image cache for high-res images
|
||||||
static void configureCache() {
|
static void configureCache() {
|
||||||
PaintingBinding.instance.imageCache.maximumSize = 1000;
|
PaintingBinding.instance.imageCache.maximumSize = 16;
|
||||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 256 * 1024 * 1024; // 256 MB
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
class ImageCanvas extends StatefulWidget {
|
class ImageCanvas extends StatefulWidget {
|
||||||
@@ -37,6 +38,19 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
bool _ctrlPressed = false;
|
bool _ctrlPressed = false;
|
||||||
bool _shiftPressed = false;
|
bool _shiftPressed = false;
|
||||||
|
|
||||||
|
// ── Kare hızına eşitlenmiş render tick ──
|
||||||
|
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
||||||
|
bool _frameScheduled = false;
|
||||||
|
|
||||||
|
void _markRenderDirty() {
|
||||||
|
if (_frameScheduled) return;
|
||||||
|
_frameScheduled = true;
|
||||||
|
SchedulerBinding.instance.scheduleFrameCallback((_) {
|
||||||
|
_frameScheduled = false;
|
||||||
|
_renderTick.value++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _clamp() {
|
void _clamp() {
|
||||||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||||||
if (_angle != 0) return; // rotate varsa serbest pan
|
if (_angle != 0) return; // rotate varsa serbest pan
|
||||||
@@ -107,6 +121,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
||||||
|
_renderTick.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +154,15 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _resetView() {
|
void _resetView() {
|
||||||
setState(() {
|
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero;
|
||||||
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero;
|
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||||||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
_markRenderDirty();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFillFit() {
|
void _toggleFillFit() {
|
||||||
setState(() {
|
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero;
|
||||||
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero;
|
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||||||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
_markRenderDirty();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDoubleTap() => _toggleFillFit();
|
void _handleDoubleTap() => _toggleFillFit();
|
||||||
@@ -175,14 +188,14 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
}
|
}
|
||||||
_sc = newSc;
|
_sc = newSc;
|
||||||
_clamp();
|
_clamp();
|
||||||
setState(() {});
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails d) {
|
void _onPanUpdate(DragUpdateDetails d) {
|
||||||
_tx += d.delta.dx;
|
_tx += d.delta.dx;
|
||||||
_ty += d.delta.dy;
|
_ty += d.delta.dy;
|
||||||
_clamp();
|
_clamp();
|
||||||
setState(() {});
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPointerDown(PointerDownEvent e) {
|
void _onPointerDown(PointerDownEvent e) {
|
||||||
@@ -194,13 +207,12 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
if (_lastRightTapTime != null &&
|
if (_lastRightTapTime != null &&
|
||||||
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
|
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
|
||||||
(e.localPosition - _lastRightTapPos).distance < 20) {
|
(e.localPosition - _lastRightTapPos).distance < 20) {
|
||||||
setState(() {
|
_angle = 0;
|
||||||
_angle = 0;
|
_contrast = 1.0;
|
||||||
_contrast = 1.0;
|
_saturation = 1.0;
|
||||||
_saturation = 1.0;
|
_brightness = 0;
|
||||||
_brightness = 0;
|
_interactionMode = 'none';
|
||||||
_interactionMode = 'none';
|
_markRenderDirty();
|
||||||
});
|
|
||||||
_lastRightTapTime = null;
|
_lastRightTapTime = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,26 +241,19 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
|
|
||||||
switch (_interactionMode) {
|
switch (_interactionMode) {
|
||||||
case 'contrast':
|
case 'contrast':
|
||||||
setState(() {
|
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
|
||||||
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'rotate':
|
case 'rotate':
|
||||||
setState(() {
|
_angle = _startAngle + dy * 0.9;
|
||||||
_angle = _startAngle + dy * 0.9;
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'saturation':
|
case 'saturation':
|
||||||
setState(() {
|
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
|
||||||
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'brightness':
|
case 'brightness':
|
||||||
setState(() {
|
_brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0);
|
||||||
_brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPointerUp(PointerUpEvent e) {
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
@@ -334,9 +339,15 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
return Image.file(
|
final longest = _vpSize.longestSide;
|
||||||
|
final cacheWidth = longest > 0 ? (longest * 2).round() : null;
|
||||||
|
final ImageProvider<Object> provider = cacheWidth != null
|
||||||
|
? ResizeImage.resizeIfNeeded(
|
||||||
|
cacheWidth, null, FileImage(File(widget.filePath)))
|
||||||
|
: FileImage(File(widget.filePath));
|
||||||
|
return Image(
|
||||||
key: _imageKey,
|
key: _imageKey,
|
||||||
File(widget.filePath),
|
image: provider,
|
||||||
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
|
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
errorBuilder: (_, _, _) => _errorWidget(),
|
errorBuilder: (_, _, _) => _errorWidget(),
|
||||||
@@ -349,65 +360,73 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_vpSize = constraints.biggest;
|
_vpSize = constraints.biggest;
|
||||||
_clamp();
|
_clamp();
|
||||||
|
return RepaintBoundary(
|
||||||
final vpCenterX = _vpSize.width / 2;
|
child: ValueListenableBuilder<int>(
|
||||||
final vpCenterY = _vpSize.height / 2;
|
valueListenable: _renderTick,
|
||||||
final angleRad = _angle * math.pi / 180;
|
builder: (context, _, _) => _buildRenderLayer(),
|
||||||
|
|
||||||
Widget imageContent = Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0;
|
|
||||||
final image = _isFilled
|
|
||||||
? SizedBox.expand(child: _buildImage())
|
|
||||||
: Center(child: _buildImage());
|
|
||||||
return hasFilter
|
|
||||||
? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image)
|
|
||||||
: image;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_angle != 0) {
|
|
||||||
imageContent = Transform(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
transform: Matrix4.identity()
|
|
||||||
..translate(vpCenterX, vpCenterY)
|
|
||||||
..rotateZ(angleRad)
|
|
||||||
..translate(-vpCenterX, -vpCenterY),
|
|
||||||
child: imageContent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SizedBox.expand(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
ClipRect(
|
|
||||||
child: Listener(
|
|
||||||
onPointerDown: _onPointerDown,
|
|
||||||
onPointerMove: _onPointerMove,
|
|
||||||
onPointerUp: _onPointerUp,
|
|
||||||
onPointerSignal: (event) {
|
|
||||||
if (event is PointerScrollEvent) _handleScroll(event);
|
|
||||||
},
|
|
||||||
child: GestureDetector(
|
|
||||||
onDoubleTap: _handleDoubleTap,
|
|
||||||
onPanUpdate: _onPanUpdate,
|
|
||||||
child: Transform(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
transform: Matrix4.identity()
|
|
||||||
..translate(_tx, _ty)
|
|
||||||
..scale(_sc),
|
|
||||||
child: imageContent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildRenderLayer() {
|
||||||
|
final vpCenterX = _vpSize.width / 2;
|
||||||
|
final vpCenterY = _vpSize.height / 2;
|
||||||
|
final angleRad = _angle * math.pi / 180;
|
||||||
|
|
||||||
|
Widget imageContent = Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0;
|
||||||
|
final image = _isFilled
|
||||||
|
? SizedBox.expand(child: _buildImage())
|
||||||
|
: Center(child: _buildImage());
|
||||||
|
return hasFilter
|
||||||
|
? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image)
|
||||||
|
: image;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_angle != 0) {
|
||||||
|
imageContent = Transform(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..translate(vpCenterX, vpCenterY)
|
||||||
|
..rotateZ(angleRad)
|
||||||
|
..translate(-vpCenterX, -vpCenterY),
|
||||||
|
child: imageContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
ClipRect(
|
||||||
|
child: Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerSignal: (event) {
|
||||||
|
if (event is PointerScrollEvent) _handleScroll(event);
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
onDoubleTap: _handleDoubleTap,
|
||||||
|
onPanUpdate: _onPanUpdate,
|
||||||
|
child: Transform(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..translate(_tx, _ty)
|
||||||
|
..scale(_sc),
|
||||||
|
child: imageContent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _errorWidget() {
|
Widget _errorWidget() {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
Reference in New Issue
Block a user