言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

RecyclerView in RecyclerViewだとAppBarLayoutがスクロールしないことがある

Nested Scroll(RecyclerView in RecyclerView)とAppBarの組み合わせでうまくいかないことがあったのでメモします。

前提

RecyclerViewが入れ子になってNested Scrollする画面で、

  • Parent RecyclerViewはvertical scroll
  • Child RecyclerViewはhorizontal scroll

といった挙動をします。

ParentのRecyclerViewCoordinatorLayoutを親に持っていて、AppBarLayoutがParentのRecyclerViewのスクロールに応じてアニメーションするといった挙動を想定しています。

問題

ParentのRecyclerViewをスクロールさせてもAppBarLayoutは変化しません。 (水色のViewがAppBarLayoutに包まれています。)

f:id:muumuumuumuu:20181204090257g:plain:w200

(同じParentにスクロールしないchildを入れてやり、その上からスクロールを開始するとAppBarはちゃんと正常にアニメーションします。)

直し方

Child RecyclerViewNestedScrollingEnabled() にfalseを設定してやればうまくいきます。

コードからだとこんな感じ

childRecyclerView.isNestedScrollingEnabled = false

xmlからも指定できます

<android.support.v7.widget.RecyclerView 
    ...(省略)...
    android:nestedScrollingEnabled="false"

上記のようにChild `RecyclerViewがnested scrollしないことを明示的に指定するだけでAppBarがアニメーションするようになります。

f:id:muumuumuumuu:20181204091114g:plain:w200

何が起こったか?

setNestedScrollingEnabled() を設定することによってどこが変わるかというと、NestedScrollingChildHelper#dispatchNestedPreScroll()がfalseを返すようになります。

RecyclerViewのdispatchNestedPreScroll() を呼んだ先でNestedScrollingChildHelper#dispatchNestedPreScroll() -> ViewParentCompat.onNestedPreScroll() -> CoordinatorLayout#onNestedPreScroll() -> AppBarLayout.BaseBehavior#onNestedPreScroll() と伝播していきます。

Scrollしない時はAppBarLayoutの onNestedPreScroll() のdyの値が正常に渡って来ません。

    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
        if (this.isNestedScrollingEnabled()) {

                ViewParentCompat.onNestedPreScroll(parent, this.mView, dx, dy, consumed, type);

        }

        return false;
    }

ここでisNestedScrollingEnabled() がfalseを返さないと本来子のRecyclerViewはVerticalなscrollをしないはずなのにonNestedPreScroll() のdyの値が変なまま伝播していってしまってうまくAppBarLayoutがアニメーションできないのかな?と予想しています。(RecyclerViewが巨大すぎて細かい部分まで追えていないのでもし違っていたらコメントで指摘してもらえると助かります。)

AppBarLayout.BaseBehavior#onNestedPreScroll()scroll()が呼ばれます。

        public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, T child, View target, int dx, int dy, int[] consumed, int type) {
            if (dy != 0) {
                int min;
                int max;
                if (dy < 0) {
                    min = -child.getTotalScrollRange();
                    max = min + child.getDownNestedPreScrollRange();
                } else {
                    min = -child.getUpNestedPreScrollRange();
                    max = 0;
                }

                if (min != max) {
                    consumed[1] = this.scroll(coordinatorLayout, child, dy, min, max);
                    this.stopNestedScrollIfNeeded(dy, child, target, type);
                }
            }

        }

この先HeaderBehavior#scroll()setHeaderTopBottomOffset()などを呼んで最終的にView#offsetTopAndBottom()が呼ばれます。 ここで不正に渡って来たdyの値によって、スクロールしていなかったことになっているんじゃないかなぁ。

ちなみにsetNestedScrollingEnabled()はデフォルトでtrueを返すの?と思った人がいるかもしれませんが、このフラグはRecyclerViewのコンストラクタでtrueが設定されます。

    public RecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {

        boolean nestedScrollingEnabled = true; // defaultでtrue
        if (attrs != null) {

            if (VERSION.SDK_INT >= 21) {
                a = context.obtainStyledAttributes(attrs, NESTED_SCROLLING_ATTRS, defStyle, defStyleRes);
                nestedScrollingEnabled = a.getBoolean(0, true); // xmlで指定されていれば反映する
                a.recycle();
            }

        this.setNestedScrollingEnabled(nestedScrollingEnabled);
    }

selectableItemBackgroundはBackgroundにセットしないと意図した挙動にならない話

タイトルが意味わからないことになっている😇

はじめに

AndroidAPI Level 21 (Android 5.0 / Lollipop) からRipple Effectがサポートされ、これによりより良いタッチフィードバックをユーザに提供できることができる。ボタンなど、「もともとタップが想定されているView」については、タップした際にデフォルトでRipple Effectが表示されている。ただのViewだったりTextViewなど「デフォルトでタップが想定されていないView」についてもシステムが用意したdrawable resourceを設定することで簡単に実現できる。

システムが用意したdrawable resourceは(自分の知る限り)2種類ある。

  • ?android:attr/selectableItemBackgroundBorderless
  • ?android:attr/selectableItemBackground

これをViewのbackground/foregroundにセットするかでまた挙動が変わるという面白い現象を見つけたので調べてみた。

組み合わせでどう変わる?

まずは2種類のresourceの説明をそれぞれしていこう。Ripple Effectのcolorやdurationは変わらないのだが、selectableItemBackgroundBorderlessの方はその名の通りBorderless、つまりViewのboundaryを超えてRipple Effectを表示することができる。幅・高さが小さいViewに対してタッチフィードバックをつけるのに大変便利だ。ただし、ViewのBackgroundにセットした場合のみで、Foregroundにセットした時にはselectableItemBackgroundと同じ挙動になる。

f:id:muumuumuumuu:20181014155223g:plain:w300

(上記のサンプルコードはリンクを参照)
さて、それぞれどうして差が出るのかコードを追っていこう。

それぞれ指定されたResourceは何をやっているのか?

Borderless

selectableItemBackgroundBorderlessを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight" />

Borderlessじゃない方

selectableItemBackgroundを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight">
     19     <item android:id="@id/mask">
     20         <color android:color="@color/white" />
     21     </item>
     22 </ripple>

これらのrippleタグは最終的にRippleDrawableに変換される。

BackgroundとForegroundで挙動が異なる

RippleDrawableクラスの公式ドキュメントを見ると下記のような記載がある。

If no child layers or mask is specified and the ripple is set as a View background, the ripple will be drawn atop the first available parent background within the View's hierarchy. In this case, the drawing region may extend outside of the Drawable bounds.

わざわざ "as a View background" と書いてあるように、backgroundに指定した場合のみViewのhierarchyを辿ってparentのbackground内まで描画することができるようだ。

せっかくなのでコードを読んでみよう

RippleDrawableはLayerDrawableをextendsしているのでlayerを重ねることができる。これでmaskをかけている。

    189     public RippleDrawable(@NonNull ColorStateList color, @Nullable Drawable content,
    190             @Nullable Drawable mask) {
    191         this(new RippleState(null, null, null), null);


    201         if (mask != null) {
    202             addLayer(mask, null, android.R.id.mask, 0, 0, 0, 0);
    203         }

このmastが影響してくるのはdraw()の時

    688     @Override
    689     public void draw(@NonNull Canvas canvas) {
    690         pruneRipples();
    691 
    692         // Clip to the dirty bounds, which will be the drawable bounds if we
    693         // have a mask or content and the ripple bounds if we're projecting.
    694         final Rect bounds = getDirtyBounds();
    695         final int saveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
    696         canvas.clipRect(bounds);
    697 
    698         drawContent(canvas);
    699         drawBackgroundAndRipples(canvas);
    700 
    701         canvas.restoreToCount(saveCount);
    702     }

getDirtyBounds() はmaskがある時とないときで挙動が変わる

    922     @Override
    923     public Rect getDirtyBounds() {
    924         if (!isBounded()) {
    925             final Rect drawingBounds = mDrawingBounds;
    926             final Rect dirtyBounds = mDirtyBounds;
    927             dirtyBounds.set(drawingBounds);
    928             drawingBounds.setEmpty();
    929 
    930             final int cX = (int) mHotspotBounds.exactCenterX();
    931             final int cY = (int) mHotspotBounds.exactCenterY();
    932             final Rect rippleBounds = mTempRect;
    933 
    934             final RippleForeground[] activeRipples = mExitingRipples;
    935             final int N = mExitingRipplesCount;
    936             for (int i = 0; i < N; i++) {
    937                 activeRipples[i].getBounds(rippleBounds);
    938                 rippleBounds.offset(cX, cY);
    939                 drawingBounds.union(rippleBounds);
    940             }
    941 
    942             final RippleBackground background = mBackground;
    943             if (background != null) {
    944                 background.getBounds(rippleBounds);
    945                 rippleBounds.offset(cX, cY);
    946                 drawingBounds.union(rippleBounds);
    947             }
    948 
    949             dirtyBounds.union(drawingBounds);
    950             dirtyBounds.union(super.getDirtyBounds());
    951             return dirtyBounds;
    952         } else {
    953             return getBounds();
    954         }
    955     }

さて、ここまででselectableItemBackgroundBorderlessselectableItemBackgroundの違いがわかった。 次はBackgroundとForegroundで挙動が変わるところをみてみよう。

RippleDrawable#isProjected()というメソッドの中で、タップされた場所から円を描いて自分のサイズに収まるかどうかをみている。maskされたlayerが存在する場合もfalseを返している。

  344     @Override
    345     public boolean isProjected() {
    346         // If the layer is bounded, then we don't need to project.
    347         if (isBounded()) {
    348             return false;
    349         }
    350 
    351         // Otherwise, if the maximum radius is contained entirely within the
    352         // bounds then we don't need to project. This is sort of a hack to
    353         // prevent check box ripples from being projected across the edges of
    354         // scroll views. It does not impact rendering performance, and it can
    355         // be removed once we have better handling of projection in scrollable
    356         // views.
    357         final int radius = mState.mMaxRadius;
    358         final Rect drawableBounds = getBounds();
    359         final Rect hotspotBounds = mHotspotBounds;
    360         if (radius != RADIUS_AUTO
    361                 && radius <= hotspotBounds.width() / 2
    362                 && radius <= hotspotBounds.height() / 2
    363                 && (drawableBounds.equals(hotspotBounds)
    364                         || drawableBounds.contains(hotspotBounds))) {
    365             return false;
    366         }
    367 
    368         return true;
    369     }

このメソッドが呼ばれるのはViewクラスのgetDrawableRenderNode().

   19433     private RenderNode getDrawableRenderNode(Drawable drawable, RenderNode renderNode) {
   19434         if (renderNode == null) {
   19435             renderNode = RenderNode.create(drawable.getClass().getName(), this);
   19436         }

   19457         renderNode.setProjectBackwards(drawable.isProjected());

ここでsetしたProjectBackwardsが呼ばれるのはこの辺?(Nativeコード詳しくないマンなので間違ってたらごめんなさい)

    101 void RenderNodeDrawable::forceDraw(SkCanvas* canvas) {


    120     //pass this outline to the children that may clip backward projected nodes
    121     displayList->mProjectedOutline = displayList->containsProjectionReceiver()
    122             ? &properties.getOutline() : nullptr;
    123     if (!properties.getProjectBackwards()) {
    124         drawContent(canvas);
    125         if (mProjectedDisplayList) {
    126             acr.restore(); //draw projected children using parent matrix
    127             LOG_ALWAYS_FATAL_IF(!mProjectedDisplayList->mProjectedOutline);
    128             const bool shouldClip = mProjectedDisplayList->mProjectedOutline->getPath();
    129             SkAutoCanvasRestore acr2(canvas, shouldClip);
    130             canvas->setMatrix(mProjectedDisplayList->mProjectedReceiverParentMatrix);
    131             if (shouldClip) {
    132                 clipOutline(*mProjectedDisplayList->mProjectedOutline, canvas, nullptr);
    133             }
    134             drawBackwardsProjectedNodes(canvas, *mProjectedDisplayList);
    135         }
    136     }
    137     displayList->mProjectedOutline = nullptr;
    138 }

確かにProjectedされたoutlineをclipしているように見える。

で、話を戻してViewクラスのgetDrawableRenderNode()がいつ呼ばれるかというと、View#drawBackground()からのみコールされている。

   19375     private void drawBackground(Canvas canvas) {
   19376         final Drawable background = mBackground;
   19377         if (background == null) {
   19378             return;
   19379         }
   19380 
   19381         setBackgroundBounds();
   19382 
   19383         // Attempt to use a display list if requested.
   19384         if (canvas.isHardwareAccelerated() && mAttachInfo != null
   19385                 && mAttachInfo.mThreadedRenderer != null) {
   19386             mBackgroundRenderNode = getDrawableRenderNode(background, mBackgroundRenderNode);
   19387 
   19388             final RenderNode renderNode = mBackgroundRenderNode;
   19389             if (renderNode != null && renderNode.isValid()) {
   19390                 setBackgroundRenderNodeProperties(renderNode);
   19391                 ((DisplayListCanvas) canvas).drawRenderNode(renderNode);
   19392                 return;
   19393             }
   19394         }

というわけでbackgroundとforegruondで挙動に差分が出るのはここでした。解決!

Shared element transitionでリストの要素を並び替える

やりたいこと

Fragmentを切り替えてReordering(要素を並び替え)する shared element transition の記事を見て楽しそうだったからやってみた。 medium.com

全く同じことをやっても楽しくないので、Fragment <-> Activityでやってみる。

できた

いきなりだけど成果物

f:id:muumuumuumuu:20180909122945g:plain

コードはこの辺

github.com

やったこと

Activityで shared element transition するには startActivity() するときに第2引数にtransition animationのbundleを渡してやる必要がある。 transition animationを作るのに必要なのは下記のPair.

  • 移動元になるview
  • 上記viewに対して一意になるID

今回はリストの並び替えなので上記のpairを並び替えたいViewの数だけ持つ必要がある。 RecyclerView でリストを表示している場合、Adapterの onBindViewHolder で各Viewにアクセスできるのでこれを保持しておく。

class ReorderingAdapter(private val activity: Activity)
    : RecyclerView.Adapter<ReorderingViewHolder>() {

    val items = mutableListOf<ImageView>() // adapterのpropertyとしてmutableListを持つ

    override fun onBindViewHolder(holder: ReorderingViewHolder, position: Int) {
        holder.bind(position)
        if (!items.contains(holder.image)) {
            items.add(holder.image) // 画面に表示されたタイミングでlistに追加
        }
    }

}

実際に並び替えを実行するタイミングで保持していたviewのリストからtransition animation用のbundleを生成する。

fab.setOnClickListener {
    context?.let {
        val itemList = mutableListOf<Pair<View, String>>()
        (recycler.adapter as? ReorderingAdapter)?.items?.forEachIndexed { index, view ->
            itemList.add(Pair(view, IMAGE_TRANSITION_NAME + index))
        }

        val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
            activity as Activity, *itemList.toTypedArray()
        ).toBundle()
        ReorderingActivity.start(it, options)
    }
}

並び替え後のviewの方でも同じIDを設定する。

itemView.setTag(R.id.position, position)
ViewCompat.setTransitionName(image, IMAGE_TRANSITION_NAME + position)

あとは普通の Shared element transition のように postponeEnterTransition() したり見た目の微調整して終わり。

注意点として、画面に表示されていないものはanimationの対象にならない。 そもそも onBindViewHolder() を通らないとitemsのlistにaddできないが、仮にlistに存在していたとしても画面外だったらanimationされない。RecyclerViewを使っていたのですでにrecycleされてしまっている場合はviewがなくなっちゃってるからanimationできないのかな。

おまけ

transition animation用のbundleを生成する時に ActivityOptionsCompat#makeSceneTransitionAnimation() を使うが、このメソッドの第2引数が可変長引数になっている。

    @NonNull
    @SuppressWarnings("unchecked")
    public static ActivityOptionsCompat makeSceneTransitionAnimation(@NonNull Activity activity,
            Pair<View, String>... sharedElements) {
        if (Build.VERSION.SDK_INT >= 21) {
            android.util.Pair<View, String>[] pairs = null;
            if (sharedElements != null) {
                pairs = new android.util.Pair[sharedElements.length];
                for (int i = 0; i < sharedElements.length; i++) {
                    pairs[i] = android.util.Pair.create(
                            sharedElements[i].first, sharedElements[i].second);
                }
            }
            return createImpl(ActivityOptions.makeSceneTransitionAnimation(activity, pairs));
        }
        return new ActivityOptionsCompat();
    }

可変長引数にListを渡すときは一度Arrayに型変換してからspread operator(*)を使う。

*itemList.toTypedArray()

2018年前半を振り返る

はじめに

2018年前半に自分が何をしていたか後から振り返られるようにメモを残しておく。2017年のまとめと同じく自分のtweetを振り返ってペタペタしていく。本当は年末にまとめて一年分やりたかったがtweet数が多すぎて一気にやると辛いという前回の反省を生かして2018年は半分に区切っていく。

前回のまとめはこちら

muumuutech.hatenablog.com

1月

この時点で割と仕事に余裕があるときは隙あらば有休消化に励んでいた気がする。

普段アニメをあまりみないけどずっと気になっていた廻るピングドラムを見始めた。最初はサイコホラーなのかサスペンスなのかシュールコメディなのかわからなかったけど後半一気に止まらない感じがすごかった。
社のslackに「生存戦略」のスタンプがあってなんだろうと思っていたけど元ネタがわかってスッキリ。

長らく続けていたAndroidもくもく会、自分の退職に伴い最終回を迎えた。運営している自分もとっても楽しかったし本当にありがとうございました!

今年は(まだ半分しか終わってないけど)これと同じ内容5回くらいつぶやいている気がする。今でも絶対にフォローしているつもりだけど実はフォローしていない人いっぱいいそうだ。

DroidKaigi appにコントリビュートする実績解除した。

DroidKaigi preludeでgfxさんと二人でセッション解説をやった。会場にいた誰よりも楽しんでいた自信があるw

最終出社日、最後のPRで自分で自分を消すやつ、一度やってみたかったので記念スクショ撮ってた。

1月は送別会とかでたくさん美味しいもの食べたり、寄せ書きが嬉しすぎて号泣したり忙しかった。幸せなことだな〜〜〜

2月

2月から丸一ヶ月有休消化で寒すぎて引きこもりまくってた。

DroidKaigi、今年はオーディエンス参加だった。濃い二日間でした!

今年は初めて確定申告した。一度フローを理解するとそんなでもないはずなんだけど、初回はなかなかつらみがあった。

2月にこんなことをつぶやいているけど、2018年後半始まっても未だに毎月スターエンジニアが誰かしら転職している気がする。

Paymoの残高まだまだ余ってます。Paymo支払いの飲み会とかやりたい。ただしPaymoで支払う側として。

有休消化でのんびり南の島にバカンスに行った。一人で。

主にビーチサイドで本を読んでいたんだけど、そのとき読んだ本の記録はこちら。 muumuutech.hatenablog.com

日本に戻って来たら花粉が飛び始めていた。

3月

新しい職場。外国人の同僚も多いのでここから英語も併記したりし始めている。

花粉がマジで辛い

新しい職場でももくもく会をやっていくぞ!

次回の開催はこちら

mercari-android-mokumoku.connpass.com

Flutter盛り上がって来た

shibuya.apkがライブ配信されるようになった。DeployGateさんのオフィスで配信見ながら女子エンジニア会。美味しいお料理とお酒で最高だった

友人の家でバーフバリ鑑賞会をやった。ボリウッド映画多分初めてだったけど楽しかった!

4月

Android Dagashiデビューしてテンションが上がっていた

IWDのトークセッションに参加した。 kinukoさんkeynoteが素晴らしすぎて、この資料は定期的に見返したい



Flutter勉強会やった

勉強会の前に自分でも触ってみるか〜と思って試したらこのザマ

築地市場が移転する前に早朝のツアーに行ってみた。

ツアーは基本的に英語だし参加者もほとんど外国人でここどこだっけ?ってなった

この日は築地だけじゃなく色々日本っぽいところを巡るデートをしていた

5月

出張で福岡に来た

移動中の飛行機のお供で読んだOKR本よかった!

OKR(オーケーアール) シリコンバレー式で大胆な目標を達成する方法

OKR(オーケーアール) シリコンバレー式で大胆な目標を達成する方法

Kotlin愛好会で登壇した

6月

出張でアメリカのオフィスに一週間ほど行った。

海外オフィスでの雑な感想が今までで一番伸びた

UberiPhone置き忘れて大変だった

muumuutech.hatenablog.com

久しぶりに新卒で入った会社の人たちと飲んでいた。普段エンジニアの中でもweb系の本当に限られた人としかあってなかったんだなーと実感した

おまけ

夫シリーズ

Android Frameworkのコードにbreakpointを止めるメモ

Android開発をしていると、Frameworkがどういう挙動をしているか調べたくなる時がある。そういう時はFrameworkのコードにbreakpointを置くんだけど、止まってくれたり止まらなかったりすることがあるので困っていた。

このTweetに対して神リプライがついたので流れないようにメモしておく。

実際試したところ、Compile SDK versionと合わせたEmulatorを作ってそこで動かすというのが良さそう。

StateListAnimatorを使ってXMLだけでAnimationをつける

こちらの記事を読んで「<selector> の中にobject animator埋め込めるの知らなかった!!すげー!!!!」となったので遊んでみたメモ。

android.jlelse.eu

StateListAnimator

AndroidにはStateListAnimatorというクラスがあって、Viewのdrawable stateによってAnimationを書き分けることができる。何が最高かってこのAnimationはXMLでお手軽にかけるってところだ。1

ドキュメントはこちら

遊んでみた

アイコンに触っている間だけ大きくなるアニメーションを書いてみた。 xmlはこんな感じ

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1.5" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="10dp" />
        </set>
    </item>
    <item>
        <set>
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleX"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="scaleY"
                android:valueTo="1" />
            <objectAnimator
                android:duration="200"
                android:propertyName="transitionZ"
                android:valueTo="0dp" />
        </set>
    </item>
</selector>

android:state_pressed="true" の時とそうでない場合でObjectAnimatorの振る舞いを変えることができる。上記公式ドキュメントのリンク先を参照するとpress以外のstateもたくさんある。

xmlobjectAnimatorタグで使えるattribute一覧はこちら

Animation resources  |  Android Developers

    <objectAnimator
        android:propertyName="string"
        android:duration="int"
        android:valueFrom="float | int | color"
        android:valueTo="float | int | color"
        android:startOffset="int"
        android:repeatCount="int"
        android:repeatMode=["repeat" | "reverse"]
        android:valueType=["intType" | "floatType"]/>

このanimationとviewを紐づける時はこんな感じでandroid:stateListAnimatoを使う。

android:stateListAnimator="@animator/fav_animator"

こんな感じでハートがドキドキする。

f:id:muumuumuumuu:20180718234247g:plain

サンプルコードはこちら。

github.com


  1. 個人的には最高だと思うけど、Viewの振る舞いをXMLに書くことに対して反対派の人はいると思うので、そこはもうお好みで :pray:

KotlinのRegexとDestructuredで文字列からdata classに変換する

Androidでアプリを書いていて、正規表現を扱うときにjava.util.regex.Matcherを使うこと多いが、あれは個人的には好きではない。もっとスッキリかけるんじゃないかなぁといつも思ってしまう。

例えば、URLからprotocolとdomainを正規表現を使って取り出すコードを書こうと思うとこんな感じになるかと思う。

    companion object {
        private const val REGEX: String = "(.*)://(.*)"
    }

    fun hoge() {
        val urlString = "http://www.com"
        val url = generateUrlFromString(urlString)
    }

    private fun generateUrlFromString(url: String): Url? {
        val matcher = Pattern.compile(REGEX).matcher(url)
        return if (matcher.find()) {
            val protocol = matcher.group(1)
            val domain = matcher.group(2)
            return Url(protocol, domain)
        } else {
            null
        }
    }

data class Url(val protocol: String, val domain: String)

再帰の場合に注意が必要で、上記の場合だとmatcher.group()が0ではなく1と2だったり何かとやらかしてしまったりする。(matcher.group(0)にはhttp://www.comが入ってくる)

そんなときに、この記事見て最高では????と思ったので自分でも試してみることにした。一行でまとめると Destructuredクラスが最高では?という話です。やっぱりKotlinは可愛い。

medium.com

上記のgenerateUrlFromString()Java正規表現からKotlinの正規表現に書き換えたコードがこちら。

    private fun generateUrlFromString(url: String): Url? =
        REGEX.toRegex().matchEntire(url)
            ?.destructured
            ?.let { (protocol, domain) ->
                Url(protocol, domain)
            }

これだけで十分可愛さが伝わる気がするが、蛇足ながら可愛いポイントを書いていく。

  1. 文字列REGEXtoRegex()Regexクラスに変換
  2. matchEntire()で引数url stringを渡すことによりMatchResultを取得
  3. 2で正規表現にマッチしなかった場合はnullになるので、?.destructuredを取得。これは正規表現にマッチしたgroupをDestructuredクラスで返してくれる
  4. 3ですでにnullの可能性があるので引き続き?.でletを呼ぶ。ポイントはここでラベルをつけることができるという点。サンプルコードだとprotocoldomainをすぐに使っているが、let blockのなかで複雑なことをしている場合にわざわざ名前をつけるために変数に代入しなくてもよくて可読性がグッと上がる。

これらをワインラインでスッキリかけるところがまた可愛い!

当然ながらKotlinのRegexJavaのMatcherとかをwrapしているので、めんどくさいところは全てやってくれていて最高。

言いたいことは以上です。