第22回コベチケの会

コベリンの最近の取り組みとして、業務などで役立ちそうな知見を共有する会を開催することになりました。 そのついでに発表に使ったアジェンダもそのまま公開してしまおうという豪快な企画です。

※ アジェンダをそのままコピペして公開したものなので若干見にくい箇所もあるかもしれませんが、ご了承ください。

Jetpack Compose の BottomNavigationBar を調べた @numa08

BottomNavigationBar のカスタマイズを調査したのでその記録。

こういう感じで画面下部のタブのアイコンがはみ出して強調表示されたUIをBottomNavigationBarで実現できないだろうか?

BottomNavigationBar

画面下部に表示されるナビゲーションバー。iOSだとタブとか。昔のAndroidだと非推奨なUIだったのも懐かしい。

実装を調べる

BottomNavigation.kt - Android Code Search

  • BottomNavigationBar

Jetpack Compose内の実装を参考にしつつ、何やっているのかを読み解く。

@Composable
fun BottomNavigation(
    /** 省略 */
    selected: Boolean,
    icon: @Composable () -> Unit,
    label: @Composable (() -> Unit)? = null,    
    content: @Composable RowScope.() -> Unit // BottomNavigationBarに表示する要素。
) {
    Surface(
        color = backgroundColor,
        contentColor = contentColor,
        elevation = elevation,
        modifier = modifier
    ) {
        Row(
            Modifier
                .fillMaxWidth()
                .height(BottomNavigationHeight)
                .selectableGroup(),
            horizontalArrangement = Arrangement.SpaceBetween,
            content = content
        )
    }    
}

Rowを使って並べているだけ。Barの高さはBottomNavigationHeight=56dpで固定されている。

  • BottomNavigationItem
fun RowScope.BottomNavigationItem(
    /** 省略 */
) {
    Box(
        modifier
            .selectable(/** 省略 */)
            .weight(1f),
        contentAlignment = Alignment.Center
    ) {
        BottomNavigationTransition(
            selectedContentColor,
            unselectedContentColor,
            selected
        ) { progress ->
            val animationProgress = if (alwaysShowLabel) 1f else progress

            BottomNavigationItemBaselineLayout(
                icon = icon,
                label = styledLabel,
                iconPositionAnimationProgress = animationProgress
            )
        }
    }    
}

weight(1f)が指定されているので横幅を設定しても、すべての要素で画面中を収めるように広まってしまう。横幅のカスタムはできない。マテリアルデザインでBottomNavigationItemはアイコンとラベルで構成するよう定義されていて、その実装はBottomNavigationItemBaselineLayoutにある。

  • BottomNavigationItemBaselineLayout
@Composable
private fun BottomNavigationItemBaselineLayout(
    icon: @Composable () -> Unit,
    label: @Composable (() -> Unit)?,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    iconPositionAnimationProgress: Float
) {
    Layout(
        {
            Box(Modifier.layoutId("icon")) { icon() }
            if (label != null) {
                Box(
                    Modifier
                        .layoutId("label")
                        .alpha(iconPositionAnimationProgress)
                        .padding(horizontal = BottomNavigationItemHorizontalPadding)
                ) { label() }
            }
        }
    ) { measurables, constraints ->
        val iconPlaceable = measurables.first { it.layoutId == "icon" }.measure(constraints)

        val labelPlaceable = label?.let {
            measurables.first { it.layoutId == "label" }.measure(
                // Measure with loose constraints for height as we don't want the label to take up more
                // space than it needs
                constraints.copy(minHeight = 0)
            )
        }

        // If there is no label, just place the icon.
        if (label == null) {
            placeIcon(iconPlaceable, constraints)
        } else {
            placeLabelAndIcon(
            )
        }
    }
}

アイコンとラベルを並べているだけ。パラメータに渡されるiconPositionAnimationProgressはアイコンの透過度を変えるためのアニメーションの進捗。BottomNavigationBarをタップして画面遷移が発生すると透過度を操作するアニメーションが実行される。実態はanimateFloatAsStateで管理された値。

結論

BottomNavigationBar自体の高さが固定で、その中の要素もBottomNavigationBar内に描画されるためBottomNavigationBarを使った目的のデザインの実装は難しそう。自前でBottomNavigationBarっぽいものを作れば実現可能で、Jetpack composeのレイアウトシステムならレイアウトは難しくない。画面遷移時の透過に関するアニメーションの実装も忘れずに。

余談

AndroidXのBottomNavigationBarViewは項目をメニューリソース指定して、ラベルやアイコンなどほぼカスタムできなかったがJetpack ComposeはBottomNavigationItemのiconとかで好きなレイアウトを指定してカスタムができて楽。自前でBottomNavigationBarっぽいものを作ろうとするとFragment Transactionの管理とかも大変だったけれど、それもNavigation Componentと連携して楽になるのでAndroid開発がどんどん楽になっているなと思う。

rxでエラーを変更する @takkumattsu

変更系(update)のAPIで通信してエラーが来た時に変更前に戻したい時に、エラーをカスタムする方法

catchを使う

struct UserInfo {
    let id: String
    let name: String
    let age: Int
    let profile: String
}


class ViewController: UIViewController {
    var user: UserInfo!
    let disposedBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        let client = APIClient()
        // プロフィール設定
        client.getUser(id: "1")
            .asDriver(onErrorDriveWith: .empty())
            .drive(with: self, onNext: { $0.user = $1 })
            .disposed(by: disposedBag)
            
        let buttonTap = PublishSubject<Void>()
        // プロフィール更新
        buttonTap
            .map { _ in (id: self.user.id, profile: "更新") }
            .flatMap { client.updateProfileAPI(id: $0.id, profile: $0.profile) }
            .subscribe(with: self, onNext: {
                $0.user = $1
            }, onError: { _, _ in
                // エラーの場合は以前の状態に戻したい、でもAPIは以前の情報を返してくれない
            })
            .disposed(by: disposedBag)
    }
    
}

class APIClient {
    let data: [UserInfo] = [.init(id: "1", name: "AAA", age: 2, profile: "はじめまして!"),
                            .init(id: "2", name: "BBB", age: 12, profile: "よろしく!")]
    func updateProfileAPI(id: String, profile: String) -> Single<UserInfo> {
        guard let userInfo = data.first(where: { $0.id == id }) else {
            return .error(NSError(domain: "IDが一致しなかった", code: -99))
        }
        return .just(.init(id: userInfo.id, name: userInfo.name, age: userInfo.age, profile: profile))
    }
    func getUser(id: String) -> Single<UserInfo> {
        guard let userInfo = data.first(where: { $0.id == id }) else {
            return .error(NSError(domain: "IDが一致しなかった", code: -99))
        }
        return .just(userInfo)
    }
}

これを.catch { error in .error(CustomError(beforeUserInfo: userInfo, originalError: error)) }する

struct CustomError: Error {
    let beforeUserInfo: UserInfo
    let originalError: Error
}

class ViewController: UIViewController {
    var user: UserInfo!
    let disposedBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        let client = APIClient()
        // プロフィール設定
        client.getUser(id: "1")
            .asDriver(onErrorDriveWith: .empty())
            .drive(with: self, onNext: { $0.user = $1 })
            .disposed(by: disposedBag)
        
        let buttonTap = PublishSubject<Void>()
        // プロフィール更新
        buttonTap
            .map { _ in self.user }
            .flatMap( { userInfo in
                return client
                    .updateProfileAPI(id: userInfo.id, profile: userInfo.profile)
                    .catch { error in .error(CustomError(beforeUserInfo: userInfo, originalError: error)) }
            })
            .subscribe(with: self, onNext: {
                $0.user = $1
            }, onError: { _, _ in
                // エラーの場合は以前の状態に戻したい、でもAPIは以前の情報を返してくれない
            })
            .disposed(by: disposedBag)
    }
    
}

Swift のライブラリ作る上で気がついたこと @mironal

モジュール名と同じ Type が宣言されていると名前空間が使えなくなる

TwitterAPIKit には TwitterAPIKit という class があって判明した.

// Swift はこんな感じにモジュール名付きで型を指定できる
let hoge: ModuleName.Type = .init()

でも同じ名前の型(structclass)、上の例だと ModuleName というモジュールに `class ModuleName` とかが宣言してあると

// コンパイルエラーになる
let hoge: ModuleName.ModuleName = .init()

一応問題だと認識してる様子.

https://forums.swift.org/t/fixing-modules-that-contain-a-type-with-the-same-name/3025

そのためこんな修正をした.

https://github.com/mironal/TwitterAPIKit/pull/125

Protocol しか公開してないと RxSwift の extension が作れない

RxSwift で Reactive な extension (.rx.) ってアクセスするやつをやりたいときには ReactiveCompatible という protocol に準拠する必要がある。 NSObject はデフォルトで実装されているからあんまり意識することはない。

https://github.com/ReactiveX/RxSwift/blob/1a1fa37b0d08e0f99ffa41f98f340e8bc60c35c4/RxSwift/Reactive.swift

ライブラリ側が プロパティーを protocol で公開していると その protocol は ReactiveCompatible に準拠できないため rx という感じのアクセスができなくなる.

※ Swift は protocol を別のprotocol に準拠させることはできない.

// これはエラーになる
extension HogeProtocol: HugaProtocol {}

なので protocol ではなく class を公開することにした.

https://github.com/mironal/TwitterAPIKit/pull/127

小話

protocol を公開するとライブラリのユーザー側が拡張できないから class で公開するほうが理にかなっているなと感じた.

protocol は成約や要求みたいなものだから TwitterAPIKit みたいなライブラリは特にそういう成約や要求をしないから protocol ではなく class で公開するほうが理にかなってるなと感じた。