Onsen UI for Vueで、リストのスライダーメニューを実装しました。
最近のUIで左スライドで削除というアイテムの削除が流行っています。
本記事では、リストのスライダーメニューで削除要素の表示、
クリック後、リストのアイテムの削除。の実装をご紹介します。
もしかしたら私のリサーチ不足で、スライダーメニューで削除のライブラリなりがあったかも・・しれませんが、一先ずgsapを使用して作成しました。
完成例
下のGIF画像のように作成しました。
OnsenUIなだけあって、ネイティブアプリのようなUIが簡単に作成できます。
要件
・左スライドで削除要素の表示。
・左スライド時点からドラッグ終了時の差が最大値の場合、削除要素は開く。
・左スライド時点からドラッグ終了値の差が最大値以外の場合、削除要素を閉じる。
・他要素をクリックした場合、スライドされたメニューは閉じる。
・削除要素をクリックした場合、リストのアイテムを削除する。
上のような要件に基づき実装しました。
では、ソースを記していきます。
ソース
slider.vue
<template> <v-ons-page> <v-ons-list> <v-ons-list-item class="swipeListWrap" lock-on-drag v-for="item in items" :key="item.id" > <v-ons-row class="swipeList row px-0 mg-auto" :style="{ left: 0 - animatedNumber(item.id) + 10 + '%' }" > <v-ons-col class="item swipeListTarget" :data-order-id="item.id" width="70%" > <v-ons-row class="row px-0 mg-auto swipeListTarget" :data-order-id="item.id" > <v-ons-col class="col-12 swipeListTarget" :data-order-id="item.id" > {{ item.name }} </v-ons-col> </v-ons-row> </v-ons-col> <v-ons-col class="itemDeleteWrap" width="30%" @click="deleteFnc(item)" > <div class="itemDelete"> <span class="itemDeleteText">削除</span> </v-ons-col> </v-ons-row> </v-ons-list-item> </v-ons-list> </v-ons-page> </template> <script> import gsap from "gsap"; export default { data() { return { number: 0, tweenedNumber: 0, dragstartX: 0, initialPosition: 30, targetOrderId: 0, animateLocked: false, movementPosition: 0, items: [ { id: 0, name: "おれんじ", }, { id: 1, name: "いちご", }, { id: 2, name: "ぶどう", }, ], }; }, mounted: function () { let _this = this; this.$ons .GestureDetector(this.$el) .on("dragstart drag dragend touch", (e) => { if (e.target.matches(".swipeListTarget") && !_this.animateLocked) { _this.targetOrderId = e.target.getAttribute("data-order-id"); var range = _this.dragstartX - e.gesture.center.pageX; if (e.type == "dragstart") { _this.dragstartX = e.gesture.center.pageX; } else if (e.type == "dragend") { _this.number = range < 0 + 80 ? 0 : _this.initialPosition; } else if (e.type == "drag") { _this.number = range < 0 ? 0 : range && _this.initialPosition <= range ? _this.initialPosition : range; } } else { _this.number = 0; } }); }, methods: { deleteFnc: function (item) { //削除処理 this.items = Object.values(this.items).filter((el) => { return el.id != item.id; }, 0); this.animateLocked = false; }, }, computed: { animatedNumber: function () { return function (uid) { this.movementPosition = this.tweenedNumber.toFixed(0); if (uid != this.targetOrderId) { return 0; } else if (this.movementPosition == this.initialPosition) { this.animateLocked = true; return this.movementPosition; } if (this.movementPosition == 0) { this.animateLocked = false; } return this.movementPosition; }; }, }, watch: { number: function (newValue) { gsap.to(this.$data, { duration: 0.4, tweenedNumber: newValue, }); }, }, }; </script> <style scoped> .swipeListWrap { position: relative; height: 50%; min-height: 70px; width: 130%; } .swipeList { position: absolute; width: 100%; height: 100%; align-items: center; } .item { position: relative; display: flex; align-items: center; height: 100%; } .itemDeleteWrap { height: 100%; } .itemDelete { display: block; height: 100%; background: red; width: 100%; } .itemDeleteText { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; background: red; color: white; width: 100%; } </style>
・number
スライドメニューの右端を示しています。
どれぐらいメニューが開いているか座標が返ります。・tweenedNumber
numberの値が変化するとアニメーションが実行され、
tweenedNumberが変化します。
tweenedNumberはアニメーション後の値を代入するので、
numberを遅れて代入するという認識です。
リスト内でdragを開始したポイントを記録します。
・initialPosition
削除要素を表示した際の移動範囲です。
・targetOrderId
イベントのある要素のカスタムデータを取得、代入します。
data-order-idにより、ページのIDを代入しています。
・animateLocked
連続してアニメーション実行のイベントが発生した場合、それらを受け付けるかどうかを判定するフラグです。
・movementPosition
移動した距離です。
mounted: function () { let _this = this; this.$ons.GestureDetector(this.$el) .on("dragstart drag dragend touch", (e) => { if (e.target.matches(".swipeListTarget") && !_this.animateLocked) { _this.targetOrderId = e.target.getAttribute("data-order-id"); var range = _this.dragstartX - e.gesture.center.pageX; if (e.type == "dragstart") { _this.dragstartX = e.gesture.center.pageX; } else if (e.type == "dragend") { _this.number = range < 0 + 80 ? 0 : _this.initialPosition; } else if (e.type == "drag") { _this.number = range < 0 ? 0 : range && _this.initialPosition <= range ? _this.initialPosition : range; } } else { _this.number = 0; } } ); },
dragstart drag dragend touchのイベントが発生した場合に実行されるコードです。
タッチしたポイントからドラッグ終了をrangeとして、
それぞれのポイントに応じ処理を変更しています。
computed: { animatedNumber: function () { return function (uid) { this.movementPosition = this.tweenedNumber.toFixed(0); if (uid != this.targetOrderId) { return 0; } else if (this.movementPosition == this.initialPosition) { this.animateLocked = true; return this.movementPosition; } if (this.movementPosition == 0) { this.animateLocked = false; } return this.movementPosition; }; },
それぞれのリストアイテムで持っているanimatedNumberにおける処理です。
イベントの発生した要素とそれ以外の要素で分けて、スライドした要素のみスライドするように設定してあります。
また、前回イベント時のスライドの状態から処理を分けています。
おわりに
今回は、スライダーメニューで削除要素の表示及び削除を実装しました。
簡単に実装できるのでは?と思っていましたが、所々で躓いてしまいました。
例えば、複数リストアイテムに対して
computed内で行ってる処理が共通化されているため、変数の管理をするため、
変数の状態管理で苦労しました。
アイテムAをスライドメニューを開いているときにアイテムBのスライドメニューに触れると、アイテムAは閉じ、アイテムBが開いてしまうという事象です。しかもアニメーションが適用されませんでした。
改めて自ら作成してみると、大手SNSのUIは最適化されているなと思いました。
ストレスのなく操作ができますよね。
ただ、個人的にはInstagramよりTwitterの方が洗練されていると感じます。
スクロールの手の動きって上下ではなく、斜めです。
人間工学に基づいたTwitterは、操作ミスが少ないです。
一方でInstagramは不意に画面スワイプや画面をクローズしてしまいます。
こういう小さな違いが多くのユーザを獲得するか否か。
サービス内容と同様に重要なポイントですね。
以上、ありがとうございました。
コメント