|
| 1 | +package com.tool.tree |
| 2 | + |
| 3 | +import android.app.Activity |
| 4 | +import android.view.View |
| 5 | +import androidx.activity.BackEventCompat |
| 6 | +import androidx.activity.OnBackPressedCallback |
| 7 | +import androidx.activity.OnBackPressedDispatcher |
| 8 | + |
| 9 | +/** |
| 10 | + * Hiệu ứng "vuốt để trở lại" (Predictive Back Gesture - Android 13+/API 33+) cho 1 Activity. |
| 11 | + * |
| 12 | + * Mặc định, dùng `onBackPressedDispatcher.addCallback(this) { ... }` (callback đơn giản) |
| 13 | + * sẽ CHẶN MẤT hiệu ứng preview: hệ thống chỉ gọi hàm xử lý DUY NHẤT một lần khi người dùng |
| 14 | + * đã thả tay xong cử chỉ, giống hệt kiểu Back cũ của AOSP (đóng màn hình đột ngột, không có |
| 15 | + * animation theo tiến trình kéo). |
| 16 | + * |
| 17 | + * Helper này đăng ký một callback "biết" theo dõi toàn bộ tiến trình cử chỉ: |
| 18 | + * - handleOnBackStarted / handleOnBackProgressed: được gọi liên tục trong lúc ngón tay đang |
| 19 | + * kéo từ cạnh màn hình vào giữa. Ta dùng progress (0f -> 1f) để scale nhẹ nội dung màn hình |
| 20 | + * hiện tại (100% -> minScale) và dịch nó theo đúng hướng cạnh đang vuốt (trái/phải), tạo |
| 21 | + * cảm giác "kéo dần ra để lộ màn trước" giống Android hiện đại. |
| 22 | + * - handleOnBackPressed: được gọi khi người dùng THẢ TAY và cử chỉ hoàn tất -> thực thi hành |
| 23 | + * động back thật sự ([onBack], ví dụ finish() hoặc quay lại 1 bước trong ứng dụng). |
| 24 | + * - handleOnBackCancelled: được gọi khi người dùng kéo dở rồi rút tay lại giữa chừng (không |
| 25 | + * hoàn tất cử chỉ) -> animate mượt nội dung trở lại đúng như ban đầu (100% scale, vị trí gốc). |
| 26 | + * |
| 27 | + * Trên các máy/API < 33 (không hỗ trợ predictive back ở tầng hệ thống), các hàm |
| 28 | + * handleOnBackStarted/Progressed/Cancelled đơn giản là không được gọi - hành vi tự động |
| 29 | + * "rớt" về giống như back thường (không animation), không cần code riêng xử lý. |
| 30 | + */ |
| 31 | +class PredictiveBackHelper( |
| 32 | + private val activity: Activity, |
| 33 | + private val contentView: View, |
| 34 | + // Tỉ lệ scale nhỏ nhất khi kéo hết cỡ (progress = 1f). Vd 0.9f = thu nhỏ còn 90%. |
| 35 | + private val minScale: Float = 0.9f, |
| 36 | + // Khoảng dịch chuyển ngang tối đa (px) khi kéo hết cỡ, tạo cảm giác bị "kéo" theo tay. |
| 37 | + private val maxTranslatePx: Float = 48f, |
| 38 | + // Hành động back thật sự khi cử chỉ hoàn tất (thường là finish(), hoặc logic back riêng |
| 39 | + // của từng màn hình như WebView.goBack(), thoát thư mục con...). |
| 40 | + private val onBack: () -> Unit |
| 41 | +) { |
| 42 | + fun register(dispatcher: OnBackPressedDispatcher) { |
| 43 | + dispatcher.addCallback(activity, object : OnBackPressedCallback(true) { |
| 44 | + |
| 45 | + override fun handleOnBackStarted(backEvent: BackEventCompat) { |
| 46 | + contentView.pivotY = contentView.height / 2f |
| 47 | + } |
| 48 | + |
| 49 | + override fun handleOnBackProgressed(backEvent: BackEventCompat) { |
| 50 | + val progress = backEvent.progress.coerceIn(0f, 1f) |
| 51 | + |
| 52 | + val scale = 1f - (1f - minScale) * progress |
| 53 | + contentView.scaleX = scale |
| 54 | + contentView.scaleY = scale |
| 55 | + |
| 56 | + // EDGE_LEFT (vuốt từ mép trái vào) -> nội dung dịch sang phải theo tay. |
| 57 | + // EDGE_RIGHT (vuốt từ mép phải vào) -> nội dung dịch sang trái theo tay. |
| 58 | + val direction = if (backEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1f else -1f |
| 59 | + contentView.translationX = direction * maxTranslatePx * progress |
| 60 | + } |
| 61 | + |
| 62 | + override fun handleOnBackPressed() { |
| 63 | + onBack() |
| 64 | + // Nếu onBack() không kết thúc Activity (vd chỉ lùi 1 bước trong app, như |
| 65 | + // WebView.goBack() hoặc thoát thư mục con), khôi phục lại giao diện về trạng |
| 66 | + // thái ban đầu vì người dùng vẫn đang ở lại màn hình này. |
| 67 | + // Nếu Activity đang finish() thì bỏ qua reset để animation thu nhỏ tiếp tục |
| 68 | + // mượt mà cho tới khi màn hình biến mất, không bị "giật" về rồi mới đóng. |
| 69 | + if (!activity.isFinishing) { |
| 70 | + resetContentView(animated = true) |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + override fun handleOnBackCancelled() { |
| 75 | + resetContentView(animated = true) |
| 76 | + } |
| 77 | + |
| 78 | + private fun resetContentView(animated: Boolean) { |
| 79 | + if (animated) { |
| 80 | + contentView.animate() |
| 81 | + .scaleX(1f) |
| 82 | + .scaleY(1f) |
| 83 | + .translationX(0f) |
| 84 | + .setDuration(180) |
| 85 | + .start() |
| 86 | + } else { |
| 87 | + contentView.scaleX = 1f |
| 88 | + contentView.scaleY = 1f |
| 89 | + contentView.translationX = 0f |
| 90 | + } |
| 91 | + } |
| 92 | + }) |
| 93 | + } |
| 94 | +} |
0 commit comments