コベリンの最近の取り組みとして、業務などで役立ちそうな知見を共有する会を開催することになりました。 そのついでに発表に使ったアジェンダもそのまま公開してしまおうという豪快な企画です。
※ アジェンダをそのままコピペして公開したものなので若干見にくい箇所もあるかもしれませんが、ご了承ください。
- Jetpack Compose の BottomNavigationBar を調べた @numa08
- rxでエラーを変更する @takkumattsu
- Swift のライブラリ作る上で気がついたこと @mironal
Jetpack Compose の BottomNavigationBar を調べた @numa08
BottomNavigationBar のカスタマイズを調査したのでその記録。
こういう感じで画面下部のタブのアイコンがはみ出して強調表示されたUIを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() でも同じ名前の型(struct や class)、上の例だと ModuleName というモジュールに `class ModuleName` とかが宣言してあると // コンパイルエラーになる let hoge: ModuleName.ModuleName = .init()
一応問題だと認識してる様子.
https://forums.swift.org/t/fixing-modules-that-contain-a-type-with-the-same-name/3025
そのためこんな修正をした.
Protocol しか公開してないと RxSwift の extension が作れない
RxSwift で Reactive な extension (.rx.
) ってアクセスするやつをやりたいときには ReactiveCompatible
という protocol に準拠する必要がある。 NSObject はデフォルトで実装されているからあんまり意識することはない。
ライブラリ側が プロパティーを protocol で公開していると その protocol は ReactiveCompatible に準拠できないため rx
という感じのアクセスができなくなる.
※ Swift は protocol を別のprotocol に準拠させることはできない.
// これはエラーになる extension HogeProtocol: HugaProtocol {}
なので protocol ではなく class を公開することにした.
小話
protocol を公開するとライブラリのユーザー側が拡張できないから class で公開するほうが理にかなっているなと感じた.
protocol は成約や要求みたいなものだから TwitterAPIKit みたいなライブラリは特にそういう成約や要求をしないから protocol ではなく class で公開するほうが理にかなってるなと感じた。