NewsPicksアプリ開発におけるUIデザイン開発の具体例を紹介します

こんにちは。NewsPicks Androidアプリエンジニアの sefwgweo です。 突然ですが、皆様はニュースアプリにおけるアプリ開発ってどんなイメージをお持ちでしょうか。なぜこんな問いかけをするかというと、カジュアル面談や採用面談をしていると、わりとそこそこの割合で「ニュースアプリで何をそんなに沢山開発することがあるんですか?」という質問をいただくためです。 しかも、ニュースアプリやそれに近しいアプリ開発に関わった経験がある方から質問されることが多かったのも印象的でした。 そんな時は、だいたい以下のいくつかについてお話をすると納得していただくことが多かったです。

  1. 不定期におきるリニューアル
  2. 動画配信システムとの連携
  3. 課金周り
  4. 多種多様なログ要件
  5. 仕様や要件が複雑な様々なモジュール
  6. 高度なデザイン要件

1〜5に関しては別の機会にお話するとして、今回は6について具体例をもとに紹介します。

高度なデザイン要件具体例

表現の仕方が難しかったので表題な感じにしていますが、大まかには以下のような要件でした。

1. 縦スクロールRecyclerView内の1モジュールで、必ず1番上にあること
2. オリジナル画像に対してモノトーン加工されたモノトーン画像A(以下左)と、Aの下にオリジナル画像B(以下右)を重ねて斜めの矩形部分をスクロールと同期させ、その部分を透過させてほしい(透過状態の箇所はオリジナル画像Aが見えるようにしたい)

3. 画像自体の位置は固定だが矩形部分はスクロールの上下に連動し、画像可視領域に応じてアルファがかかり徐々に暗くなってほしい
4. 上記機能をメモリやCPUに大きな負担をかけない方法で実現してほしい

という感じでした。 特にこのモジュールに名前があったわけではないですがチーム内では斜めスラッシュという名でコミュニケーションしていました。

概要

・要件1,2、3の状態別詳細
 ・画像A、Bを重ねたものをリスト最上部に固定表示し、特定領域透過
 ・特定領域の透過位置をスクロールと同期
 ・可視領域に応じてアルファ値が変化

・実際の挙動(アニメーションGIF)

どのへんが難しかったか

要件1は特段問題ないので省きます。
要件2はオリジナル画像と加工済画像はサーバからAPIで取得できるとのことだったので加工部分も省きます。
要件2の透過を要件4の省エネルギーで実現というのが今回難関だった部分となります。
まずフィルターをかける時点で真っ先に思い浮かんだのがGPUImageを使うことでしたが、 要件4によりあえなく却下となりました。
同様の理由で一旦OpenCVやffmpeg等も一旦保留としました。
悩んだ末行き着いたのはAPIレベル1から存在するPorterDuffでした。
https://developer.android.com/reference/android/graphics/PorterDuff.Mode
透過まではすぐできたのですが、要件3のスクロール連動の箇所で数日詰まりました。

ではどう実現したかをここからはコードを見つつ解説していきます(一部説明に不要な部分は省略しています)。 XMLからいきます。
Listの各要素を表現するためのXML: row_dummy.xml
特筆事項なし。

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="160dp"
        android:background="#191919">
        <!-- Listの各要素に入るタイトル(1st item, 2nd item等) -->
        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textColor="@color/white"
            android:textSize="18sp"
            android:textStyle="bold"
            android:paddingVertical="8dp"
            android:paddingHorizontal="8dp"
            tools:text="dummy text title!"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
        <!-- Listの各要素の区切り線 -->
        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="@color/white"
            app:layout_constraintBottom_toBottomOf="@+id/title"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

Listのヘッダ部分専用XML: row_header.xml
要件3(画像可視領域に応じてアルファが変化)を満たすための工夫としてview_top_headerが定義されています(後述)。

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="375dp">
        <FrameLayout
            android:id="@+id/top_container"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">
            <FrameLayout
                android:id="@+id/view_top_header"
                android:layout_width="match_parent"
                android:layout_height="375dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent">
                <!-- 要件3の、画像可視領域に応じた透明度を変えるためのView -->
                <View
                    android:layout_width="match_parent"
                    android:layout_height="125dp"
                    android:layout_gravity="bottom"
                    android:background="@drawable/bg_head_line_title" />
            </FrameLayout>
        </FrameLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>

Listと今回の画像部分を構成するXML: fragment_see_through_list.xml
今回一番キーポイントとなるClearViewが登場(後述)。

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <!-- 加工済画像Aとオリジナル画像Bを重ねるためのレイアウト -->
        <FrameLayout
            android:id="@+id/header_container"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginBottom="70dp"
            android:background="#181818">
            <!-- オリジナル画像B -->
            <ImageView
                android:id="@+id/original_image"
                android:layout_width="match_parent"
                android:layout_height="375dp"
                android:adjustViewBounds="true"
                android:scaleType="centerCrop"
                android:src="@mipmap/original" />
            <!-- 加工済画像Aと矩形透過View -->
            <jp.domus.seethroughrecyclerview.ClearView
                android:id="@+id/masked_container"
                android:layout_width="match_parent"
                android:layout_height="375dp">
                <!-- 加工済画像A -->
                <ImageView
                    android:id="@+id/masked_image"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:adjustViewBounds="true"
                    android:scaleType="centerCrop"
                    android:src="@mipmap/masked"/>
            </jp.domus.seethroughrecyclerview.ClearView>
        </FrameLayout>
       <!-- メインとなるRecyclerView -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

続いてKotlinファイル
透過を表現するためのカスタムView: ClearView.kt
透過する長方形をPorterDuffで作り、内部で30度の傾斜をつけています。
movePositionメソッドで表示位置をListと同期するように調整しています。

class ClearView : FrameLayout {
    // マスクされた画像の上に乗せる斜めの透過する四角のサイズ
    private val paint = Paint()
    private val leftPosition = 0
    private val topPosition = 100
    private val rightPosition = 2300
    private val bottomPosition = 380
    private val rect = Rect(leftPosition, topPosition, rightPosition, bottomPosition)
    override fun dispatchDraw(canvas: Canvas) {
        super.dispatchDraw(canvas)
        setLayerType(View.LAYER_TYPE_HARDWARE, null)
        canvas.rotate(30f) // 透過する四角を30度傾ける
        canvas.drawRect(rect, paint.apply {
            // 透過させる
            xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
        })
    }
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle)
    fun movePosition(dy: Int) {
        rect.offset(0, -dy)
        // dyのポジションのみだと斜めの四角の位置がおかしくなるため、以下の条件で位置をリセットしている
        if (rect.bottom < -(bottomPosition + 500)) {
            rect.set(leftPosition, topPosition, rightPosition, -bottomPosition)
        }
        if (rect.bottom > bottomPosition) {
            rect.set(leftPosition, topPosition, rightPosition, bottomPosition)
        }
        invalidate()
    }
}

リストを表現するためのクラス: SeeThroughListFragment.kt
SeeThroughScrollListenerを追加することでClearView#movePositionにポジションを渡せるようにしています。

class SeeThroughListFragment : Fragment() {
    .....
    private fun initUIComponent() {
        val groupAdapter = GroupAdapter<GroupieViewHolder<*>>()
        binding.list.apply {
            setHasFixedSize(true)
            adapter = groupAdapter
            addOnScrollListener(SeeThroughScrollListener(binding.maskedContainer, binding.maskedImage, binding.originalImage))
        }
        items.add(HeaderItem())
        dummyList.map { items.add(DummyItem(it)) }
        groupAdapter.update(items)
    }
}

リストのスクロールリスナー: SeeThroughScrollListener.kt
addTransitionAnimationメソッド内で要件3が満たされるように位置をみて透過を設定しています。
moveTopImageメソッド内でClearViewにポジションを渡して動きと連動するようにしています。

class SeeThroughScrollListener(
    private val view: ClearView,
    private val topMaskedImageView: ImageView,
    private val topOriginalImageView: ImageView,
) : RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        moveTopImage(dx, dy)
        addTransitionAnimation(recyclerView)
    }
    // ヘッダ画像透過
    private fun addTransitionAnimation(view: RecyclerView) {
        for (i in 0..view.childCount) {
            val child = view.getChildAt(i) ?: continue
            // トップイメージView
            child.findViewById<FrameLayout>(R.id.view_top_header)?.run {
                val top = if (child.top < 0) 0 else child.top
                val bottom = if (child.bottom > view.height) view.height else child.bottom
                val visiblePercent = (bottom - top) / child.height.toFloat()
                var alphaValue = abs(1.0f - visiblePercent)
                // 可視範囲に応じてアルファをかける
                topMaskedImageView.alpha = abs(visiblePercent)
                topOriginalImageView.alpha = abs(visiblePercent)
                // 可視範囲に応じて画像を表示/非表示にしている
                if (alphaValue > 0.97) {
                    topMaskedImageView.visibility = View.GONE
                    topOriginalImageView.visibility = View.GONE
                } else {
                    topMaskedImageView.visibility = View.VISIBLE
                    topOriginalImageView.visibility = View.VISIBLE
                }
            }
        }
    }
    // ヘッダ画像透過部分を移動させる
    private fun moveTopImage(dx: Int, dy: Int) {
        view.movePosition(dy)
    }
}

以上がコードによる全容です。透過まで出来た後、どうやって矩形透過部分をスクロールと同期させるかに関する1つの解として参考になれば幸いです。 以下に今回解説したもののリポジトリを載せておきます。 https://github.com/sefwgweo/seeThroughRecycler

まとめ

NewsPicksでは今回の内容以外にも、普通にググっても解答が得られにくい独創的なUI/UXが要件としてあがってくることもあり、1つの具体例として上記を取り上げてみましたがいかがでしたでしょうか。 こちらの記事が同様の機能をこれから開発する開発者様の助けになり、また弊社のサービスに興味を持っていただけるきっかけになりましたら幸いです。

おわりに

ユーザベースではエンジニアを募集しています。ご興味ある方いらっしゃいましたらこちらからぜひご応募いただければと思います!

Page top