uniApp向け縦スクロールニュースティッカー:低コード設計からコード自動生成まで

縦方向に連続スクロールするニュースティッカーは、情報量が多く表示領域に制約がある場面で特に有効なUIパターンです。モバイルアプリやダッシュボード、デジタルサイネージなどで、限られた空間に動的に情報を流すことで、ユーザーの視線を引きつけつつ多くのコンテンツを伝達できます。以下では、このコンポーネントの設計思想から実装、自動生成までを解説します。

一、動作原理と設計方針

本コンポーネントは、表示領域の外にデータをループ配置し、margin-topを逐次更新することでスクロール動作を実現します。データの末尾と先頭をシームレスに接続することで、無限ループのような視覚効果を生み出します。

主要な設計ポイントは以下の通りです:

  • 表示行数(visibleCount)とデータ総数を比較し、少ない方を基準に初期化
  • データ配列の末尾に、先頭からvisibleCount個分の要素を複製
  • ホバー・タッチ時はsetIntervalをクリアし、離れた際に再開
  • 行ごとの移動はrequestAnimationFrameではなく、細分化されたsetIntervalで段階的に実行

二、ユースケース

場面具体例
ニュースアプリ速報タイトルの巡回表示
IoTダッシュボード異常検知ログのリアルタイム反映
店舗向けアプリキャンペーン情報の自動案内
社内ポータルお知らせ欄のコンパクト化

三、コンポーネント実装

以下に、再設計したコンポーネントの完全なコードを示します。変数名・構造を刷新しつつ、元の動作を維持しています。

<template>
  <view 
    class="vertical-ticker" 
    :style="{ height: rowPx * displayRows + 'rpx' }"
    @touchstart="haltScroll"
    @touchend="resumeScroll"
    @mouseover="haltScroll"
    @mouseleave="resumeScroll"
  >
    <view 
      class="ticker-track" 
      :style="{ transform: `translateY(-${offsetY}rpx)` }"
    >
      <slot 
        v-for="(entry, idx) in cyclicBuffer" 
        :key="idx"
        :record="entry" 
        :rowPx="rowPx"
      ></slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'VerticalNewsTicker',
  props: {
    source: { type: Array, default: () => [] },
    visibleRows: { type: Number, default: 4 },
    rowPx: { type: Number, default: 80 },
    transitionMs: { type: Number, default: 500 },
    intervalMs: { type: Number, default: 2000 }
  },
  data() {
    return {
      cyclicBuffer: [],
      offsetY: 0,
      actualRows: 0,
      cycleTimer: null,
      frameTimer: null
    }
  },
  computed: {
    maxScroll() {
      return this.source.length * this.rowPx;
    }
  },
  mounted() {
    this.bootstrap();
  },
  beforeDestroy() {
    this.clearAllTimers();
  },
  methods: {
    bootstrap() {
      this.clearAllTimers();
      this.actualRows = Math.min(this.visibleRows, this.source.length);
      this.cyclicBuffer = [...this.source, ...this.source.slice(0, this.actualRows)];
      this.offsetY = 0;
      this.cycleTimer = setInterval(() => this.shiftByOne(), this.intervalMs);
    },
    shiftByOne() {
      if (this.offsetY >= this.maxScroll) {
        this.offsetY = 0;
      }
      const stepCount = this.rowPx;
      const stepDelay = this.transitionMs / this.rowPx;
      let progressed = 0;
      
      this.frameTimer = setInterval(() => {
        this.offsetY += 1;
        progressed++;
        if (progressed >= stepCount) {
          clearInterval(this.frameTimer);
          this.frameTimer = null;
        }
      }, stepDelay);
    },
    haltScroll() {
      this.clearAllTimers();
    },
    resumeScroll() {
      this.bootstrap();
    },
    clearAllTimers() {
      if (this.cycleTimer) {
        clearInterval(this.cycleTimer);
        this.cycleTimer = null;
      }
      if (this.frameTimer) {
        clearInterval(this.frameTimer);
        this.frameTimer = null;
      }
    }
  },
  watch: {
    source: {
      deep: true,
      handler() {
        this.bootstrap();
      }
    }
  }
}
</script>

<style scoped>
.vertical-ticker {
  width: 100%;
  overflow: hidden;
  position: relative;
}
.ticker-track {
  will-change: transform;
}
</style>

四、呼び出し側の実装例

以下は、APIから取得したデータをバインングし、カスタムレイアウトで表示する実装例です。スタイル変数を整理し、可読性を向上させています。

<template>
  <view class="page-root">
    <view class="ticker-wrapper">
      <vertical-news-ticker
        v-slot="{ record }"
        :source="feedItems"
        :visibleRows="3"
        :rowPx="236"
        :transitionMs="500"
        :intervalMs="2000"
      >
        <view class="news-card" :style="{ height: '236rpx' }">
          <view class="news-body">
            <view class="headline-area">
              <text class="headline-text">{{ record.title }}</text>
            </view>
            <view class="summary-area">
              <text class="summary-text">{{ record.remark }}</text>
            </view>
          </view>
        </view>
      </vertical-news-ticker>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      feedItems: [],
      apiEndpoint: 'https://php.diygw.com/article.php'
    }
  },
  onLoad() {
    this.loadFeed();
  },
  methods: {
    async loadFeed() {
      try {
        const response = await this.$http.post(this.apiEndpoint, {}, {}, 'json');
        this.feedItems = response.data || [];
      } catch (err) {
        console.error('フィード取得失敗:', err);
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.ticker-wrapper {
  height: 708rpx;
}
.news-card {
  padding: 20rpx;
  margin: 20rpx;
  border-radius: 12rpx;
  background: #fff;
  box-shadow: 0 0 28rpx 11px rgba(218, 255, 225, 0.84),
              0 0 10rpx 1px rgba(31, 31, 31, 0.06);
}
.headline-area {
  margin: 10rpx;
}
.headline-text {
  font-weight: 700;
  font-size: 32rpx;
}
.summary-area {
  margin: 10rpx;
}
.summary-text {
  color: #34b39d;
  font-size: 28rpx;
}
</style>

五、低コードプラットフォームでの自動生成対応

本コンポーネントは、ビジュアルエディタ上で以下の属性を設定可能とすることで、コード生成器との連携を容易にしています。

設定項目説明
データソースArray/Objectバインドする配列またはAPI応答
可視行数Number同時に表示する行数
行高さNumber各行の高さ(rpx)
移動時間Number1行分のスクロールにかける時間(ms)
待機間隔Number次のスクロール開始までの間隔(ms)
スロットテンプレートVNode各行のレイアウト定義

コード生成器は、上記設定を解析し、対応するprops値を埋め込んだコンポーネントタグを出力します。スロット部分は、FLEXレイアウトエディタで構築したツリーをそのまま子要素として展開することで、デザインとロジックの分離を実現します。

六、パフォーマンスと注意点

本実装において留意すべき事項は以下の通りです:

  • タイマー管理: コンポーネント破棄時に必ず全てのsetIntervalをクリアし、メモリリークを防止する
  • タッチ・マウスの競合: モバイルとデスクトップ双方のイベントをリッスンし、重複発火を制御する
  • データ更新時のリセット: sourceプロパティ変更時はオフセットを0に戻し、表示の不整合を防ぐ
  • CSS Transformの活用: margin-topではなくtranslateYを使用し、レイアウト再計算コストを削減する(上記コードではrpx対応のため数値管理としているが、実際の運用ではpx換算後のtransform推奨)

タグ: uni-app vue.js 低コード コンポーネント設計 コード生成

8月28日 12:04 投稿