# Zoom / Pan / Clamp Mekanizması — Referans Doküman **Dosya:** `lib/widgets/image_canvas.dart` **Son güncelleme:** 23 Temmuz 2026 --- ## 1. Mimari Karar: Neden InteractiveViewer Yok? Başlangıçta `InteractiveViewer` kullanılıyordu. Ancak InteractiveViewer'ın built-in pan sınırlaması (`boundaryMargin`) çocuk widget'ın **tam boyutuna** göre çalışır. Fit modda `Center` kullanılınca, Center widget'ı viewport boyutunda olduğu halde içindeki Image daha küçüktür. IV, Image'ın değil Center'ın kenarlarına göre sınırlama yapar → Image'ın etrafında boşluk kalır. **Çözüm:** InteractiveViewer tamamen kaldırıldı. Yerine: ``` ClipRect → GestureDetector → Listener → Transform → Center/SizedBox → Image ``` - `Transform`: zoom/pan uygular (`_tx`, `_ty`, `_sc`) - `ClipRect`: taşan kısımları kırpar - `GestureDetector`: pan (drag) algılar - `Listener`: scroll (tekerlek) algılar --- ## 2. Durum Değişkenleri ```dart double _tx = 0; // viewport-space X kaydırma (piksel) double _ty = 0; // viewport-space Y kaydırma (piksel) double _sc = 1.0; // ölçek (1.0 = orijinal boyut) bool _isFilled; // true=fill mod, false=fit mod Size _vpSize; // LayoutBuilder'dan alınan viewport boyutu ``` **Dönüşüm matrisi** (Transform widget'ına verilen): ``` M = T(_tx, _ty) · S(_sc) ``` Yani: child-space'deki (cx, cy) noktası → viewport-space: `vx = cx * _sc + _tx` --- ## 3. Widget Ağacı (build metodu, satır 111–154) ``` LayoutBuilder ClipRect GestureDetector onDoubleTap → _toggleFillFit() onPanUpdate → _onPanUpdate() Listener onPointerSignal → _handleScroll() Transform(translate(_tx,_ty) · scale(_sc)) _isFilled ? SizedBox.expand(Image.file(fit: BoxFit.cover)) // FILL : Center(Image.file(fit: BoxFit.contain)) // FIT ``` - Viewport boyutu (`_vpSize`), `LayoutBuilder`'ın `constraints.biggest`'inden okunur (satır 115). Her build'te güncellenir. `context.findRenderObject()` kullanılmaz çünkü LayoutBuilder'ın verdiği constraints daha güvenilirdir. - `_imageKey`, `Image.file` widget'ına bağlıdır. Sadece errorBuilder için kullanılır (clamping'te kullanılmaz). --- ## 4. Scroll Zoom Mekanizması (satır 50–71) ``` _handleScroll(PointerScrollEvent e) ``` ```dart // 1. İmleci viewport-space'den child-space'e çevir cx = (vx - _tx) / _sc cy = (vy - _ty) / _sc // 2. Yeni ölçek (her tick'te %10) factor = 1.1 newSc = (_sc * (tekerlek_yönüne_göre factor veya 1/factor)).clamp(0.1, 10.0) // 3. Zoom'u imleç merkezli uygula _tx = _tx + cx * _sc - cx * newSc _ty = _ty + cy * _sc - cy * newSc _sc = newSc ``` **Formülün türetilmesi:** Zoom öncesi imleç altındaki child-space noktası `(cx, cy)`, viewport'ta `(vx, vy)` konumundadır. Zoom sonrası aynı noktanın yine `(vx, vy)`'de kalması için: ``` vx = _tx_old + cx * _sc_old (zoom öncesi) vx = _tx_new + cx * _sc_new (zoom sonrası, aynı nokta) → _tx_new = vx - cx * _sc_new → _tx_new = (_tx_old + cx * _sc_old) - cx * _sc_new → _tx_new = _tx_old + cx * _sc_old - cx * _sc_new ``` --- ## 5. Pan Mekanizması (satır 75–80) ``` _onPanUpdate(DragUpdateDetails d) ``` ```dart _tx += d.delta.dx; // her drag update'te delta biriktir _ty += d.delta.dy; if (_isFilled) _clampFill(_viewport()); setState(() {}); ``` `d.delta`, son update'ten beri olan değişimi verir (toplam değil). Her update'te delta eklenir, clamp yapılır, setState ile yeniden çizilir. --- ## 6. Clamp Sistemi — SADECE Fill Mod (satır 84–107) Fit modda hiçbir sınırlama yoktur. Kullanıcı özgürce pan/zoom yapabilir. ### `_clampFill(Size vp)` ``` imgL = _tx → Image'ın viewport-space sol kenarı imgR = _tx + vpw * _sc → Image'ın viewport-space sağ kenarı vw = imgR - imgL → Image'ın viewport-space genişliği ``` **Mantık:** Fill modda `SizedBox.expand(BoxFit.cover)` kullanıldığı için Image her zaman viewport'u tam doldurur. Yani Image'ın child-space genişliği = vpw. Ölçeklenmiş genişliği = `vpw * _sc`. **Yatay sınırlama:** ``` if (vw > vpw + 0.5) // Image viewport'tan geniş mi? if (imgL > 0) // sol kenar viewport içinde → sola çek _tx -= imgL else if (imgR < vpw) // sağ kenar viewport içinde → sağa çek _tx += vpw - imgR ``` **Dikey sınırlama:** Aynı mantık `imgT`/`imgB` ve `vph` ile. **Epsilon (`0.5`):** Floating-point hatalarını önlemek için. Tam eşitlik durumunda (vw = vpw) hiçbir işlem yapılmaz. --- ## 7. Fill / Fit Geçişi (satır 39–45) ``` _toggleFillFit() ``` ```dart _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; // sıfırla ``` Çift tıklandığında: - Mod değiştirilir - Transform sıfırlanır (identity) - Viewport cache'i sıfırlanır (sonraki build'te yeniden okunur) Fill → Fit: Image Center içinde BoxFit.contain ile ortalanır, sınırlama yok. Fit → Fill: Image SizedBox.expand içinde BoxFit.cover ile doldurur, sınırlama aktif. --- ## 8. Viewport Boyutu (satır 46, 115) ```dart Size _viewport() => _vpSize; ``` `_vpSize` değeri `LayoutBuilder`'ın `builder` callback'inde `constraints.biggest` olarak alınır (satır 115). Her build'te güncellenir. Neden `context.findRenderObject()` değil? - `LayoutBuilder`'ın constraints'i, widget'ın parent'tan aldığı en güncel boyut bilgisidir - `context.findRenderObject()?.size` bazen null veya eski değer döndürebilir (henüz layout olmamışsa) - `constraints.biggest` her zaman doğru ve günceldir --- ## 9. Görüntü Boyutu Nereden Alınıyor? **Fill mod:** `SizedBox.expand(BoxFit.cover)` → Image viewport'u tam doldurur. Görüntü boyutu = viewport boyutu. Clamp'ta `vp.width` ve `vp.height` kullanılır. **Fit mod:** `Center(BoxFit.contain)` → Image fitted boyutta çizilir. Clamp OLMADIĞI için görüntü boyutuna ihtiyaç yoktur. Eski versiyonlarda `_imageKey.currentContext?.findRenderObject()` ile render box okunuyordu. Bunun iki sorunu vardı: 1. `frameBuilder` içinde çağrılınca render object henüz oluşmamış olabiliyor → null veya 0 boyut dönüyor 2. `SizedBox.expand` içinde render box viewport boyutunu döndürüyor (fitted boyutu değil) --- ## 10. Özet: Hangi Fonksiyon Ne Yapıyor? | Fonksiyon | Satır | Görevi | |-----------|-------|--------| | `_handleScroll()` | 50 | Mouse tekerleği ile zoom (imleç merkezli) | | `_onPanUpdate()` | 75 | Sürükleme ile pan | | `_clampFill()` | 84 | Fill modda kenar sınırlaması | | `_toggleFillFit()` | 39 | Fill/Fit geçişi ve sıfırlama | | `_resetView()` | 33 | Yeni resim açıldığında sıfırlama | | `_viewport()` | 46 | Viewport boyutunu döndürür | | `build()` | 112 | Widget ağacını kurar, `_vpSize`'ı günceller | --- ## 11. Widget Tree (görsel) ``` ImageCanvas └─ LayoutBuilder ─(constraints.biggest→_vpSize)─ └─ ClipRect └─ GestureDetector (doubleTap, panUpdate) └─ Listener (onPointerSignal→scroll) └─ Transform [T(_tx,_ty)·S(_sc)] └─ [_isFilled ? SizedBox.expand : Center] └─ Image.file(key:_imageKey, fit:cover/contain) ``` --- ## 12. Geçmiş / Alınan Dersler 1. **InteractiveViewer çıkarıldı** — boundaryMargin çocuk widget'ın tam boyutuna göre çalışıyor, Center içindeki Image'ın asıl boyutunu bilmiyor. 2. **Center yerine SizedBox.expand denenmesi** — `_baseContentRect` ile fitted boyut hesaplandı ama `_intrinsicSize` asenkron yükleniyor, ilk frame'lerde yanlış boyut kullanılıyordu. 3. **Render box'tan boyut okuma** — `frameBuilder` içinde render object henüz oluşmamış olabiliyor (build → layout sırası). Retry mekanizması eklendi ama güvenilir olmadı. 4. **Fit modda sınırlamanın tamamen kaldırılması** — En temiz çözüm. Center widget'ı zaten layout seviyesinde ortalar. Zoom sonrası kullanıcı istediği gibi pan yapabilir. 5. **translateByDouble / scaleByDouble hatası** — `w` parametresi `1` olmalıydı, `0` kullanılınca transform bozuldu. Eski `translate`/`scale` API'sine geri dönüldü. 6. **_vpSize = Size.zero sıfırlaması (2026-08-03 düzeltildi)** — `_resetView()` ve `_toggleFillFit()` içinde `_vpSize = Size.zero` vardı. Çift tıklama (fill/fit toggle) `setState` yerine yalnızca `_markRenderDirty()` çağırdığı için LayoutBuilder yeniden çalışmıyor, `_vpSize` sıfır kalıyordu → `_handleScroll` ve `_clamp` erken return edip zoom ölüyor, pan sınırsız oluyordu. Çözüm: metot içi sıfırlama kaldırıldı; `_vpSize` yalnızca LayoutBuilder'ın `constraints.biggest`'inden beslenir (alan tanımındaki ilk değer korunur). Linux'ta da mevcuttu, Windows testinde fark edildi.