Halo, Habr! Nama saya Ilya Osintsev, saya adalah pengembang Android di Apiqa. Di bawah kucing Anda akan menemukan contoh menggunakan ViewDragHelper untuk membuat komponen antarmuka pengguna yang mirip dengan SwipeDismissBehavior, tetapi bekerja secara vertikal.
Dengan munculnya Desain Material, aplikasi telah menjadi elemen yang lebih interaktif yang merespons tindakan pengguna. Mereka tidak hanya menghemat ruang, tetapi juga memperkenalkan interaksi mikro yang menyenangkan. Di beberapa proyek kami, kami memutuskan untuk menggunakan spanduk yang bergerak secara vertikal pada mekanik gesek-untuk-pemberhentian. Untuk memberikan keaktifan pada antarmuka, spanduk harus memperhitungkan kecepatan pointer dan mengubah transparansi tergantung pada arah offset.
Mengevaluasi tugas
Dalam aplikasi Akun Pribadi kami , spanduk bertindak sebagai cara cepat untuk meninggalkan banding ke layanan pencarian penyewa untuk rumah Anda. Dalam aplikasi "Alat Kabinet", spanduk memungkinkan Anda untuk menyimpan konteks pekerjaan pengguna dengan tugas saat beralih dari kartu informasi ke komentar. Dalam kasus pertama, kami menekankan opsionalitas layanan PIK-Rent dan membiarkan klien merasa betah dalam aplikasi. Dalam kasus lain, kami menerapkan gesek saat diminta sehingga tidak tumpang tindih baris pesan antara pengirim dan pelaksana.
Untuk memulai, saya mengumpulkan demo sederhana berdasarkan SwipeDismissBehavior untuk mempelajari cara kerjanya dan memperkirakan skala perubahan. Mencoba untuk menentukannya di markup xml melempar pengecualian ketika dieksekusi:
E/AndroidRuntime: FATAL EXCEPTION: main Process: io.apiqa.android.example, PID: 1024 android.view.InflateException: Binary XML file line #115: Could not inflate Behavior subclass com.google.android.material.behavior.SwipeDismissBehavior
Pengembang melanggar kontrak Perilaku dan lupa untuk menimpa konstruktor kelas dari konteks dan AttributeSet. Sekarang Anda dapat menggunakan perilaku ini hanya dengan membuat instance dari hal itu dalam kode Anda, tetapi bahkan kemudian, perilaku tampilan secara fundamental tidak memenuhi persyaratan kami, bahkan tanpa memperhitungkan arah horizontal.

Ada pesan di log aplikasi demo yang menyatakan bahwa beberapa peristiwa sentuh tidak jatuh ke handler.
E / ViewDragHelper: Mengabaikan pointerId = -1 karena ACTION_DOWN tidak diterima untuk pointer ini sebelum ACTION_MOVE. Ini mungkin terjadi karena ViewDragHelper tidak menerima semua acara di aliran acara.
Sebagai alternatif, ada solusi yang didasarkan pada OnTouchListener
, ini memberi kita kesempatan untuk menggunakan OnClickListener, yang berarti kita harus menggambarkan hukum pergerakan spanduk dalam aktivitas. Kami tidak ingin mengubah parameter gerakan (misalnya, sensitivitas) saat kami bergerak, dan menggunakan OnTouchListener di sini tampaknya berlebihan. Selain itu, di kedua proyek kami spanduk ditempatkan di CoordinatorLayout.
Jika Anda tidak memperhitungkan getter dan setter dari parameter opsional, SwipeDismissBehavior itu sendiri cukup pendek, ia menggunakan ViewDragHelper
. Saya menemukan beberapa publikasi tentang dia di jaringan dan memutuskan untuk menulis sendiri implementasi komponen yang diperlukan.
Kami berkoordinasi dengan ViewDragHelper
ViewDragHelper adalah kelas utilitas untuk memfasilitasi dukungan seret & lepas di tingkat Tampilan. Ini melacak posisi widget dan berisi beberapa fungsi yang berguna untuk menjiwainya sepanjang satu atau dua sumbu di dalam induk ViewGroup. Untuk bekerja, ia membutuhkan penangan yang mengimplementasikan ViewDragHelper.Callback . Pawang memiliki satu metode wajib, dan agar spanduk mulai bergerak, cukup untuk mendefinisikan kembali beberapa. Secara umum, helper ini mudah digunakan, tersedia di proyek apa pun, karena dilengkapi dengan appcompat. Untuk membuat pembantu, diperlukan referensi ke induk CoordinatorLayout, jadi kami akan mengatur inisialisasi malas. Di onInterceptTouchEvent
dan onTouchEvent
kita harus memanggil metode pembantu yang sesuai, sisa logika akan berada di dalam handler.
class VerticalSwipeBehavior<V: View>: CoordinatorLayout.Behavior<V> { companion object { @Suppress("UNCHECKED_CAST") fun <V: View> from(v: V): VerticalSwipeBehavior<V> { val lp = v.layoutParams require(lp is CoordinatorLayout.LayoutParams) val behavior = lp.behavior requireNotNull(behavior) require(behavior is VerticalSwipeBehavior) return behavior as VerticalSwipeBehavior<V> } } @Suppress("unused") constructor() : super() @Suppress("unused") constructor(context: Context, attrs: AttributeSet) : super(context, attrs) private var dragHelper: ViewDragHelper? = null private var interceptingEvents = false override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { var isIntercept = interceptingEvents when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { isIntercept = parent.isPointInChildBounds(child, ev.x.toInt(), ev.y.toInt()) interceptingEvents = isIntercept } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { interceptingEvents = false } } return if (isIntercept) { helper(parent).shouldInterceptTouchEvent(ev) } else false } override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { val helper = helper(parent) val isViewUnder = helper.isViewUnder(child, ev.x.toInt(), ev.y.toInt()) if (helper.capturedView == child || isViewUnder ) { helper.processTouchEvent(ev) return true } else { return false } } private fun helper(parent: ViewGroup): ViewDragHelper { var h = dragHelper if (h == null) { h = ViewDragHelper.create(parent, callback) dragHelper = h return h } return h } }
Dalam metode tryCaptureView
handler yang diperlukan tryCaptureView
kita harus memutuskan apakah akan memindahkan tampilan tertentu. Agar helper tidak ketinggalan peristiwa yang diperlukan, kami mengakui pointer yang diterima sebelumnya. Untuk mendapatkan solusi paling fleksibel, tiga antarmuka tambahan diperkenalkan, di mana Anda dapat mengontrol desain banner secara detail:
SideEffect
mencerminkan kemajuan SideEffect
di properti tampilanVerticalClamp
dirancang untuk membatasi pergerakan tampilan secara vertikalPostAction
dipanggil setelah pengguna menghentikan swipe, di sini kita dapat melanjutkan gerakan tampilan.
Di masing-masing dari mereka, metode onViewCaptured(View)
, di sini implementasi klien dapat mengekstraksi nilai awal dari properti tampilan. Urutan panggilan ke metode ini tidak dijamin.
var sideEffect: SideEffect = AlphaElevationSideEffect() var clamp: VerticalClamp = FractionConstraintWithTopMargin(1f, 1f) var settle: PostAction = OriginSettleAction() private val callback = object: ViewDragHelper.Callback() { private val INVALID_POINTER_ID = -1 private var currentPointer = INVALID_POINTER_ID private var originTop: Int = 0 override fun tryCaptureView(child: View, pointerId: Int): Boolean { return currentPointer == INVALID_POINTER_ID || pointerId == currentPointer } override fun onViewCaptured(child: View, activePointerId: Int) { originTop = child.top currentPointer = activePointerId sideEffect.onViewCaptured(child) settle.onViewCaptured(child) clamp.onViewCaptured(child.top) } override fun onViewReleased(child: View, xvel: Float, yvel: Float) {
Meskipun pandangan kami tidak bergerak secara vertikal, kita harus ingat untuk mengimplementasikan clampViewPositionHorizontal
di handler untuk menghindari bug visual. Implementasi sederhana mengembalikan koordinat kiri, yang berarti bahwa widget tidak bergerak secara horizontal.
Dalam kasus kami, panggilan ke handler clampViewPositionVertical
didelegasikan ke antarmuka VerticalClamp . Metode constraint
harus mengembalikan koordinat ketinggian yang dibatasi oleh posisi tampilan maksimum dan / atau minimum. Ketika sudah tercapai, ViewDragHelper akan membatasi pergerakan. Metode upCast(distance, top, height, dy)
dan downCast
memiliki tanda tangan yang sama dan mengembalikan sebagian kecil dari jarak yang ditempuh, dengan mempertimbangkan posisi awal tampilan. Dalam onViewPositionChanged
penangan onViewPositionChanged
kami mendapatkan progres pemindahan dan meneruskannya ke SideEffect#apply(View, Float)
, di mana Anda dapat mengubah transparansi atau properti tampilan lainnya tergantung pada kemajuan gerakan. Jika posisi tampilan saat ini lebih tinggi dari awal, maka progres ditransmisikan dengan tanda minus.
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { return clamp.constraint(child.height, top, dy) } override fun onViewPositionChanged(child: View, left: Int, top: Int, dx: Int, dy: Int) { val factor = if (top < originTop) { val diff = originTop - top -clamp.bottomCast(diff, top, child.height, dy) } else { val diff = top - originTop clamp.topCast(diff, top, child.height, dy) } sideEffect.apply(child, factor) }
Secara default, FractionClamp
digunakan, yang membatasi pergerakan tampilan satu ketinggian ke atas dan ke bawah (koefisien diatur dalam konstruktor), AlphaElevationSideEffect
mengubah transparansi dan ketinggian spanduk. Untuk mempertimbangkan tugas selesai, Anda harus menambahkan kemampuan untuk memindahkan banner setelah pengguna merilisnya.
Saat pengguna melepaskan tampilan, helper akan mengingat kecepatan pointer dan memanggil onViewReleased
pada handler. Di dalamnya, kita dapat memulai animasi gerakan menggunakan settleCapturedViewAt
atau smoothSlideViewTo
. Menurut kontrak, setelah panggilan yang berhasil dari mereka, continueSettling
harus dipanggil pada setiap frame berikutnya sehingga pandangan terus bergerak. Dalam kasus ini, settleCapturedViewAt hanya dapat dipanggil dari metode onViewReleased ketika flag internal helper mReleaseInProgress disetel ke true. Perbedaan lainnya adalah smoothSlideViewTo tidak memperhitungkan kecepatan pointer.
override fun onViewReleased(child: View, xvel: Float, yvel: Float) { val diff = child.top - originTop if (abs(yvel) > 0) { val settled = dragHelper?.let { if (diff > 0) { settle.releasedBelow(it, diff, child) } else { settle.releasedAbove(it, diff, child) } } ?: false if (settled) { listener?.onPreSettled(diff) child.postOnAnimation(RecursiveSettle(child, diff)) } } currentPointer = INVALID_POINTER_ID }
Logika ini diringkas dalam antarmuka PostAction
. Metode releasedAbove
dan releasedBelow
dapat diimplementasikan sehingga ketika digeser ke atas, spanduk terus bergerak dengan kecepatan yang sama, meninggalkan layar, dan ketika digeser ke bawah, ia kembali ke posisi semula. Jika salah satu metode mengembalikan true, maka animasi telah dipicu dan RecursiveSettle ditambahkan ke antrian acara tampilan, yang akan tetap di dalamnya sampai animasi selesai. Secara default, OriginSettleAction
digunakan ketika tampilan pada offset apa pun kembali ke titik awal. Pilihan lain - SettleOnTopAction
saat memindahkan tampilan turun mengembalikannya ke titik awal, dan ketika bergerak lebih tinggi itu membawa Anda keluar dari layar.
class SettleOnTopAction: PostAction { private var originTop: Int = -1 override fun onViewCaptured(child: View) { originTop = child.top } override fun releasedAbove(helper: ViewDragHelper, child: View): Boolean { return helper.settleCapturedViewAt(child.left, originTop) } override fun releasedBelow(helper: ViewDragHelper, child: View): Boolean { return helper.settleCapturedViewAt(child.left, -child.height) } }
Jika perlu, Anda dapat berlangganan acara dengan mengimplementasikan antarmuka VerticalSwipeBehavior.SwipeListener
. Ini memiliki dua metode simetris, satu dipanggil sebelum dimulainya animasi bergerak, yang lain setelah itu berakhir. Argumen menunjukkan arah dan jarak di mana pengguna merilis spanduk. Hasil yang dihasilkan memenuhi persyaratan kami.

Untuk mendapatkan hasilnya, cukup mendefinisikan properti sebagai berikut:
val drag = findViewById<View>(R.id.drag) VerticalSwipeBehavior.from(drag).apply { settle = SettleOnTopAction() sideEffect = NegativeFactorFilterSideEffect(AlphaElevationSideEffect()) clamp = BelowFractionalClamp() }
Omong-omong, helper juga menyediakan fitur kontrol gerak lainnya. Misalnya, menggunakan metode setMinVelocity(Float)
, Anda dapat membatasi kecepatan minimum agar tampilan dapat dipindahkan. Helper juga mendukung pengenalan gesekan dari batas layar, untuk ini Anda perlu menentukannya dalam metode setEdgeTrackingEnabled(Int)
. Harus diingat bahwa satu instance dari ViewDragHelper dapat mengontrol pergerakan hanya satu tampilan dan hanya memperhitungkan satu pointer.
Buat kesimpulan
Pengalaman saya adalah ViewDragHelper membuatnya mudah untuk membuat drag & drop atau memindahkan panel dalam suatu aplikasi. Helper mudah digunakan di Behavior atau ViewGroup yang ditimpa. Ini memiliki beberapa metode yang berguna untuk menggerakkan tampilan dan mengontrol gerakan mereka. Mempelajari implementasi internal komponen Desain Bahan adalah pengalaman yang baik dalam karier pengembang Android. Tugas-tugas seperti dari desainer memotivasi saya untuk mengeksplorasi pendekatan baru untuk membangun antarmuka aplikasi dan berbagi pengetahuan dengan rekan kerja dan masyarakat.
Anda dapat menggunakan pustaka yang dihasilkan dalam proyek Anda. Untuk mengaktifkannya, tentukan dependensi dalam file build.gradle
dependencies { implementation 'io.apiqa.android:verticalswipebehavior:1.0.0' }
Untuk tampilan yang sesuai di dalam CoordinatorLayout, tentukan properti app:layout_behavior="io.apiqa.android.verticalswipe.VerticalSwipeBehavior"
di markup. Anda dapat menempatkan banner di dalam induk menggunakan indentasi. Dengan mengoordinasikan implementasi SideEffect , VerticalClamp, dan PostAction, Anda dapat mencapai perilaku banner yang Anda butuhkan. Di repositori , tersedia versi masing-masing yang berfungsi.
Selamat Tahun Baru!