使用Compose實作底部按鈕和首頁banner以及資料串列
- 前言
- Column、Row、ConstraintLayout布局先知
- Column縱向排列布局
- Row橫向排列布局
- ConstraintLayout 約束布局
- Modifier的簡單使用
- 底部導航欄的實作
- 首頁內容的實作
- Banner的實作
- 首頁ViewModel
- 實作文章串列
- SwipeRefresh下拉重繪
- LaunchedEffect簡介
- HorizontalPager簡介
- 原始碼地址
前言
compose作為Android現在主推的UI框架,各種文章鋪天蓋地的席卷而來,作為一名Android開發人員也是很有必要的學習一下了,這里就使用wanandroid的開放api來撰寫一個compose版本的玩安卓客戶端,全當是學習了,各位大佬輕噴~
先來看一下首頁的效果圖:

從圖片中可以看到首頁的內容主要分為三部分,頭部標題欄,banner,資料串列,底部導航欄;今天就實作這幾個功能,
Column、Row、ConstraintLayout布局先知
在Compose布局中主要常用的就是這三個布局,分別代表縱向排列布局,橫向排列布局,以及約束布局;先大概了解一下用法,以及布局包裹內部元素的排列方便在專案中更好的使用,
Column縱向排列布局
Column主要是將布局包裹內的元素由上至下垂直排列顯示,類似于Recyclerview的item,簡單來看一段代碼:
@Preview
@Composable
fun ColumnItems(){
Column {
Text(text = "我是第一個Column元素",Modifier.background(Color.Gray))
Text(text = "我是第二個Column元素",Modifier.background(Color.Green))
Text(text = "我是第三個Column元素",Modifier.background(Color.LightGray))
}
}
可以看到在一個Column里面包裹了三個Text,那么來看一下效果:

可以看到所有元素是由上至下進行排列的,
Row橫向排列布局
簡而言之就是將布局里面的元素一個一個的由左到右橫向排列,
再來看一段簡短的代碼:
@Preview
@Composable
fun RowItems(){
Row {
Text(text = "我是第一個Row元素",Modifier.background(Color.Gray).height(100.dp))
Text(text = "我是第二個Row元素",Modifier.background(Color.Green).height(100.dp))
Text(text = "我是第三個Row元素",Modifier.background(Color.LightGray).height(100.dp))
}
}
在Row里面同樣包裹了三個Text文本,再來看一下效果:

可以看到Row里面的元素是由左到右橫向進行排列的,
ConstraintLayout 約束布局
在compose里面同樣可以使用約束布局,主要主用于一些Column或者Row或者Box布局無法直接實作的布局,在實作更大的布局以及有許多復雜對齊要求以及布局嵌套過深的場景下,ConstraintLayout 用起來更加順手,在使用ConstraintLayout 之前需要先匯入相關依賴包:
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
這里額外提一句,在你創建專案的時候所有compose的相關依賴包都要和你專案當前的compose版本一致,或者都更新到最新版,如果compose的版本大于你現在匯入的其他依賴庫的版本,那么就會報錯,
在使用ConstraintLayout需要注意以下幾點:
- 宣告元素 通過 createRefs() 或 createRef() 方法初始化宣告的,并且每個子元素都會關聯一個ConstraintLayout 中的 Composable 組件;
- 關聯組件 Modifier.constrainAs(text)通過constrainAs關聯組件
- 約束關系可以使用 linkTo 或其他約束方法實作;
- parent 是一個默認存在的參考,代表 ConstraintLayout 父布局本身,也是用于子元素的約束關聯,
來看一段代碼:
@Preview
@Composable
fun ConstraintLayoutDemo(){
ConstraintLayout {
//宣告元素
val (text,text2,text3) = createRefs()
Text(text = "我是第一個元素",Modifier.height(50.dp).constrainAs(text){
//將第一個元素固定到父布局的右邊
end.linkTo(parent.end)
})
Text(text = "老二",modifier = Modifier.background(Color.Green).constrainAs(text2){
//將第二個元素定位到第一個元素的底部
top.linkTo(text.bottom)
//,然后于第一個元素居中
centerTo(text)
})
Text(text = "老三",modifier = Modifier.constrainAs(text3){
//將第三個元素定位到第二個元素的底部
top.linkTo(text2.bottom)
//將第三個元素定位在第二個元素的右邊
start.linkTo(text2.end)
})
}
}
看一下效果:

約束布局只要習慣linkTo的使用就能很好的使用該布局,
Modifier的簡單使用
Modifier在compose里面可以設定元素的寬高,大小,背景色,邊框,邊距等屬性;這里只介紹一些簡單的用法,
先看一段代碼:
modifier = Modifier
// .fillMaxSize()//橫向 縱向 都鋪滿,設定了fillMaxSize就不需要設定fillMaxHeight和fillMaxWidth了
// .fillMaxHeight()//fillMaxHeight縱向鋪滿
.fillMaxWidth()//fillMaxWidth()橫向鋪滿 match
.padding(8.dp)//外邊距 vertical = 8.dp 上下有8dp的邊距; horizontal = 8.dp 水平有8dp的邊距
.padding(8.dp)//內邊距 padding(8.dp)=.padding(8.dp,8.dp,8.dp,8.dp)左上右下都有8dp的邊距
// .width(100.dp)//寬100dp
// .height(100.dp)//高100dp
.size(100.dp)//寬高 100dp
// .widthIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified)//設定自身的最小和最大寬度(當子級元素超過自身時,子級元素超出部分依舊可見);
.background(Color.Green)//背景顏色
.border(1.dp, Color.Gray,shape = RoundedCornerShape(20.dp))//邊框
- fillMaxSize 設定布局縱向橫向都鋪滿
- fillMaxHeight 設定布局鋪滿縱向
- fillMaxWidth 設定布局鋪滿橫向,這三個屬性再使用了fillMaxSize 就沒必要在設定下面兩個了
- padding 設定邊距,方向由左上右下設定,添加了vertical就是設定垂直的上下邊距,horizontal設定了水平的左右邊距,這里注意寫了兩個padding,第一個是外邊距,第二個是內邊距,外邊距最好是放在Modifier的第一個元素,
- width 設定元素的寬
- height 設定元素的高
- size 設定元素大小,只有一個值時寬高都是一個值,.size(100.dp,200.dp)兩個值前者是寬,后者是高
- widthIn 設定自身的最小和最大寬度(當子級元素超過自身時,子級元素超出部分依舊可見)
- background 設置元素的背景顏色
- border 設定邊框,引數值:邊框大小,邊框顏色,shape
更多Modifier的設定可以查看原始碼或者官方檔案,
底部導航欄的實作
從圖中可以可以出,底部導航欄主要包含四個tab,分別是首頁、專案、分類以及我的,而每個tab又分別包含一張圖片和一個文字,

具體實作步驟:
1.撰寫每個tab的樣式,這里要使用到Column進行布局,Column列的意思,就是Column里面的元素會一個順著一個往下排的意思,所以我們需要在里面放一個圖片Icon和一個文本Text,
Column(
modifier.padding(vertical = 8.dp),//垂直(上下邊距)8dp
horizontalAlignment = Alignment.CenterHorizontally) {//對齊方式水平居中
Icon(painter = painterResource(id = iconId),//圖片資源
contentDescription = tabName,//描述
//圖片大小 //顏色
modifier = Modifier.size(24.dp),tint = tint)
// 文本 字體大小 字體顏色
Text(text = tabName,fontSize = 11.sp,color = tint)
}
因為是四個按鈕,并且有著選中和未選中的狀態,所以我們需要封裝成一個方法進行使用:
/**
* 引數決議
* @DrawableRes iconId: Int
*
* iconId 引數名稱
* Int 引數型別
* @DrawableRes 只能填入符合當前屬性的值
* */
@Composable
private fun TabItem(@DrawableRes iconId: Int, //tab 圖示資源
tabName: String,//tab 名稱
tint: Color,//tab 顏色(選中或者未選中狀態)
modifier: Modifier = Modifier
){
Column(
modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Icon(painter = painterResource(id = iconId),
contentDescription = tabName,
modifier = Modifier.size(24.dp),tint = tint)
Text(text = tabName,fontSize = 11.sp,color = tint)
}
}
2.使用Row放置四個TabItem,Row水平排列的意思,
@Composable
fun BottomBar(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
Row(
modifier
.fillMaxWidth()
.background(ComposeUIDemoTheme.colors.bottomBar)
.padding(4.dp, 0.dp)
.navigationBarsPadding(),
content = content
)
}
@Composable
fun BottomTabBar(selectedPosition: Int, currentChanged: (Int) -> Unit){
//使用Row將四個TabItem包裹起來,讓它們水平排列
BottomBar() {
TabItem(
iconId = if (selectedPosition == 0) R.drawable.home_selected else R.drawable.home_unselected,
tabName = "首頁",
tint = if (selectedPosition == 0) ComposeUIDemoTheme.colors.iconCurrent else ComposeUIDemoTheme.colors.icon,
Modifier
.clickable {
currentChanged(0)
}
.weight(1f))
TabItem(
iconId = if (selectedPosition == 1) R.drawable.project_selected else R.drawable.project_unselected,
tabName = "專案",
tint = if (selectedPosition == 1) ComposeUIDemoTheme.colors.iconCurrent else ComposeUIDemoTheme.colors.icon,
Modifier
.clickable {
currentChanged(1)
}
.weight(1f))
TabItem(
iconId = if (selectedPosition == 2) R.drawable.classic_selected else R.drawable.classic_unselected,
tabName = "分類",
tint = if (selectedPosition == 2) ComposeUIDemoTheme.colors.iconCurrent else ComposeUIDemoTheme.colors.icon,
Modifier
.clickable {
currentChanged(2)
}
.weight(1f))
TabItem(iconId = if (selectedPosition == 3) R.drawable.mine_selected else R.drawable.mine_unselected,
tabName = "我的",
tint = if (selectedPosition == 3) ComposeUIDemoTheme.colors.iconCurrent else ComposeUIDemoTheme.colors.icon,
Modifier
.clickable {
currentChanged(3)
}
.weight(1f))
}
}
TabItem填充決議:
- iconId tab圖示資源,當選中的下標等于當前tab的下標時顯示選中的資源,否則顯示非選中資源
- tabName tab文本
- tint tab 顏色,同樣分為選中和未選中
- Modifier 使用Modifier設定點擊事件,以及權重
- currentChanged(0) tabitem的點擊事件,回傳當前item的下標
TabItem(
iconId = if (selectedPosition == 0) R.drawable.home_selected elseR.drawable.home_unselected,
tabName = "首頁",
tint = if (selectedPosition == 0) ComposeUIDemoTheme.colors.iconCurrent else ComposeUIDemoTheme.colors.icon,
Modifier
.clickable {
currentChanged(0)
}
.weight(1f))
3.分別創建HomePage、ProjectPage、ClassicPage和MinePage四個頁面,頁面撰寫一些簡單的代碼鋪滿頁面即可,
@Composable
fun ClassicPage(viewModel: BottomTabBarViewModel = viewModel()){
Column(Modifier.fillMaxWidth()) {
DemoTopBar(title = "分類")
Box(
Modifier
.background(ComposeUIDemoTheme.colors.background)
//使用Modifier將頁面鋪滿
.fillMaxSize()
) {
Text(text = "分類")
}
}
}
4.使用HorizontalPager進行頁面滑動,并且與tabitem的點擊事件進行系結,達到頁面滑動切換以及點擊tabitem進行切換的效果,
HorizontalPager主要引數決議:
- count 總頁面數
- state 當前選中的頁面狀態
使用HorizontalPager需要匯入以下資源:
implementation "com.google.accompanist:accompanist-pager:$accompanist_pager"//0.20.2
具體實作步驟如下:
先通過remember記錄住當前選中的下標,這個主要作用與tabItem的切換
//記錄頁面狀態
val indexState = remember { mutableStateOf(0) }
然后通過rememberPagerState記錄HorizontalPager的currentPager也就是當前頁面下標
val pagerState = rememberPagerState()
使用HorizontalPager填充頁面
HorizontalPager(count = 4,
state = pagerState,
modifier = Modifier.fillMaxSize().weight(1f))
{ page: Int ->
when(page){
0 ->{
HomePage()
}
1 ->{
ProjectPage()
}
2 ->{
ClassicPage()
}
3 ->{
MinePage()
}
}
}
使用LaunchedEffect進行頁面切換
//頁面切換
LaunchedEffect(key1 = indexState.value, block = {
pagerState.scrollToPage(indexState.value)
})
最后系結底部導航欄并系結點擊事件
//滑動系結底部選單欄
/**
selectedPosition = pagerState.currentPage
將當前的currentPager賦值給tabitem的selectPosition對底部導航欄進行系結
indexState.value = it
將底部導航欄的點擊回呼下標賦值給indexState對pager進行系結
*/
BottomTabBar(selectedPosition = pagerState.currentPage){
indexState.value = it
}
到這里就能實作一個底部導航欄以及四個頁面的切換了,
首頁內容的實作
Banner的實作
因為獲取Banner資料要進行網路請求,至于網路封裝就不貼代碼了,這里直接從ViewModel開始展示,具體的網路代碼可以移步到專案進行觀看,
首頁ViewModel
主要用于Banner和首頁文章串列的網路請求:
class HomeViewModel : ViewModel() {
private var _bannerList = MutableLiveData(listOf<BannerEntity>())
val bannerList:MutableLiveData<List<BannerEntity>> = _bannerList
fun getBannerList(){
NetWork.service.getHomeBanner().enqueue(object : Callback<BaseResult<List<BannerEntity>>>{
override fun onResponse(call: Call<BaseResult<List<BannerEntity>>>,response: Response<BaseResult<List<BannerEntity>>>) {
response.body()?.let {
_bannerList.value = it.data
}
}
override fun onFailure(call: Call<BaseResult<List<BannerEntity>>>, t: Throwable) {
}
})
}
private var _articleData = MutableLiveData<ArticleEntityPage>()
val articleData:MutableLiveData<ArticleEntityPage> = _articleData
fun getArticleData(){
NetWork.service.getArticleList().enqueue(object : Callback<BaseResult<ArticleEntityPage>>{
override fun onResponse(call: Call<BaseResult<ArticleEntityPage>>,response: Response<BaseResult<ArticleEntityPage>>) {
response.body()?.let {
articleData.value = it.data
}
}
override fun onFailure(call: Call<BaseResult<ArticleEntityPage>>, t: Throwable) {
}
})
}
}
在呼叫HomePage的時候將HomeViewModel傳入進去,不推薦直接在compose里面直接呼叫,會重復呼叫:
val bVM = HomeViewModel()
HomePage(bVM = bVM)
HomePage的創建:
fun HomePage(viewModel: BottomTabBarViewModel = viewModel(), bVM:HomeViewModel){
}
資料呼叫進行請求,首先要創建變數通過observeAsState進行資料接收重繪
val bannerList by bVM.bannerList.observeAsState()
Compose的網路請求要放到LaunchedEffect去執行,才不會重復請求資料
val requestState = remember { mutableStateOf("") }
LaunchedEffect(key1 = requestState.value, block = {
bVM.getBannerList()
})
繪制Banner的View,這里同樣使用到HorizontalPager,并且還使用了coil進行網路加載,需要匯入相關依賴包
implementation 'io.coil-kt:coil-compose:1.3.0'
BannerView的代碼,實作大致和tabitem差不多,只是添加了一個輪播,就不做過多的極細,直接貼代碼了
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun BannerView(bannerList: List<BannerEntity>,timeMillis:Long){
Box(
Modifier
.fillMaxWidth()
.height(160.dp)) {
val pagerState = rememberPagerState()
var executeChangePage by remember { mutableStateOf(false) }
var currentPageIndex = 0
HorizontalPager(count = bannerList.size,
state = pagerState,
modifier = Modifier
.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val dragEvent = event.changes.firstOrNull()
when {
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
dragEvent.changedToDownIgnoreConsumed() -> {
//記錄下當前的頁面索引值
currentPageIndex = pagerState.currentPage
}
dragEvent.changedToUpIgnoreConsumed() -> {
if (pagerState.targetPage == null) return@awaitPointerEventScope
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
.clickable {
Log.e(
"bannerTAG",
"點擊的banner item:${pagerState.currentPage} itemUrl:${bannerList[pagerState.currentPage].imagePath}"
)
}
.fillMaxSize()) { page ->
Image(
painter = rememberImagePainter(bannerList[page].imagePath),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
//自動輪播
LaunchedEffect(key1 = pagerState.currentPage, block = {
Log.e("LaunchedEffect","${pagerState.currentPage}")
if (pagerState.currentPage >=0 && pagerState.currentPage < bannerList.size -1){
delay(timeMillis = timeMillis)
pagerState.animateScrollToPage(pagerState.currentPage +1)
}else{
delay(timeMillis = timeMillis)
pagerState.animateScrollToPage(0)
}
})
}
}
最后就行呼叫
InitBanner(bannerList= bannerList!!,2000L)
實作文章串列
資料請求就不做過多贅述了,和banner的資料請求一樣,這里主要決議一下Compose的約束布局ConstraintLayout,
Compose約束布局需要匯入以下相關依賴:
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
- val img = createRef() 創建一個依賴點,就像xml約束布局里面的id意義昂
- Modifier 使用Modifier的constrainAs進行約束調整
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)) {
val img = createRef()
Text(text = "作者:${entity.audit}")
Image(painter = painterResource(id = R.drawable.icon_un_select),
contentDescription = "收藏",
Modifier
.width(30.dp)
.height(30.dp)
.constrainAs(img) {
end.linkTo(parent.end)
},
alignment = Alignment.CenterEnd)
}
Item代碼如下
@Composable
private fun ArticleListItem(entity: ArticleEntity,modifier: Modifier = Modifier){
Card(
shape = RoundedCornerShape(10.dp),
backgroundColor = ComposeUIDemoTheme.colors.listItem,
elevation = 2.dp,modifier =
Modifier.padding(0.dp,10.dp,0.dp,0.dp)
) {
Row(
Modifier
.fillMaxWidth()
.clickable {
Log.e("articleTAG", "文章點擊")
}) {
Column(Modifier.fillMaxWidth()) {
Text(text = "${entity.title}",
Modifier.padding(8.dp,8.dp,8.dp,8.dp),
fontSize = 16.sp,
color = ComposeUIDemoTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)) {
val img = createRef()
Text(text = "作者:${entity.audit}")
Image(painter = painterResource(id = R.drawable.icon_un_select),
contentDescription = "收藏",
Modifier
.width(30.dp)
.height(30.dp)
.constrainAs(img) {
end.linkTo(parent.end)
},
alignment = Alignment.CenterEnd)
}
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(8.dp)) {
val parentView = createRef()
Text(text = "${entity.superChapterName}",
modifier = Modifier
.padding(8.dp)
.border(
1.dp, color = Color(R.color.b_666),
RoundedCornerShape(8.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp),
color = Color.Gray,
fontSize = 12.sp
)
Text(text = "${entity.niceShareDate}",modifier = Modifier.constrainAs(parentView){
end.linkTo(parent.end)
centerVerticallyTo(parent)
},color = Color.Gray,fontSize = 12.sp,textAlign = TextAlign.Center)
}
}
}
}
}
SwipeRefresh下拉重繪
操作串列的時候下拉重繪和上拉加載更多的操作肯定是少不了的,這里先說一下Compose SwipeRefresh下拉重繪組件的使用,
首先匯入依賴包:
implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_pager"//0.20.2
在ViewModel里面保存一個記錄重繪狀態的元素,在重繪請求資料程序中這個值要變成true進行重繪影片的加載,重繪完成之后要變成false關閉加載影片,
val _isRefreshing: MutableLiveData<Boolean> = MutableLiveData(false)
val isRefreshing by viewModel._isRefreshing.observeAsState(false)
SwipeRefresh(state = rememberSwipeRefreshState(isRefreshing = isRefreshing),
onRefresh = {
//將重繪狀態的值改為true 顯示加載影片
viewModel._isRefreshing.value = true
//將請求資料的值做改變 讓它能重新請求資料
requestState.value = "refresh"+System.currentTimeMillis()
}) {
//填充資料
Box(
Modifier
.background(ComposeUIDemoTheme.colors.background)
.fillMaxSize()
) {
Column(Modifier.fillMaxWidth()) {
if (bannerList !== null){
InitBanner(bannerList= bannerList!!,2000L)
}
if (articleEntityPage !== null){
InitHomeArticleList(articleData = articleEntityPage!!)
}
}
}
}
LaunchedEffect(key1 = requestState.value, block = {
bVM.getBannerList()
bVM.getArticleData({
viewModel._isRefreshing.value = false//將重繪狀態的值改成false 關閉加載狀態
})
})
首頁的內容一共就這么多了,到這里就能實作一個 app的地步導航欄以及首頁了,
LaunchedEffect簡介
LaunchedEffect存在的意義是允許我們在被Composable標注的方法中使用協程,
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
主要引數是key1,block;key1是觸發條件,當key1發生改變的時候就會執行block里面的方法,注意是發生改變的時候,
我們一開始在請求資料的時候創建了一個requestState來記錄請求狀態,如果這個狀態不發生改變,那么就只會觸發一次LaunchedEffect對資料進行請求,
//記錄請求狀態
val requestState = remember { mutableStateOf("") }
LaunchedEffect(key1 = requestState.value, block = {
bVM.getBannerList()
bVM.getArticleData({
viewModel._isRefreshing.value = false//將重繪狀態的值改成false 關閉加載狀態
})
})
而當我們在重繪的時候在onRefres里面改變了這個引數的值,那么它就會觸發LaunchedEffect重新進行資料請求,
onRefresh = {
//將重繪狀態的值改為true 顯示加載影片
viewModel._isRefreshing.value = true
//將請求資料的值做改變 讓它能重新請求資料
requestState.value = "refresh"+System.currentTimeMillis()
}
HorizontalPager簡介
HorizontalPager類似于AndroidView的ViewPager,用于頁面承載和滑動切換,主要引數是count頁面資料,以及state 當前頁面狀態,
// Display 10 items
HorizontalPager(count = 10) { page ->
// Our page content
Text(
text = "Page: $page",
modifier = Modifier.fillMaxWidth()
)
}
通過scrollToPage可以進行頁面切換,但是只能在協程里面被執行,所以同樣要使用LaunchedEffect進行頁面切換,
//頁面切換
LaunchedEffect(key1 = indexState.value, block = {
pagerState.scrollToPage(indexState.value)
})
更多用法以及demo移步以下鏈接,
https://google.github.io/accompanist/pager/
原始碼地址
碼云地址,記得切換main分支
完結~
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/356134.html
標籤:其他
