詳解Flutter混排瀑布流解決方案
背景
流式布局,這是一種當(dāng)前無(wú)論是前端,還是Native都比較流行的一種頁(yè)面布局。特別是對(duì)于商品這樣的Feeds流,無(wú)論是淘寶,京東,美團(tuán),還是閑魚。都基本上以多列瀑布流進(jìn)行呈現(xiàn),容器列數(shù)固定,然后每個(gè)卡片高度不一,形成參差不齊的多欄布局。
對(duì)于Native來(lái)說(shuō),無(wú)論是iOS還是Android,CollectionView和RecyclerView都能滿足我們的絕大部分場(chǎng)景了。不過(guò)目前閑魚很多業(yè)務(wù)場(chǎng)景都是在Flutter上進(jìn)行實(shí)現(xiàn)的,當(dāng)時(shí)Flutter官方只提供了ListView和GridView的實(shí)現(xiàn),沒(méi)有對(duì)瀑布流進(jìn)行支持。
目前社區(qū)中有兩個(gè)開源的解決方案,分別是WaterFallFlow和FlutterStaggeredGridView。但是在閑魚的場(chǎng)景中都有一些無(wú)法滿足的痛點(diǎn)。前者無(wú)法支持RecyclerView中StaggeredGridLayoutManager中setFullSpan這樣的橫跨全屏的橫條卡片混排能力能力,后者在不提前預(yù)設(shè)置卡片高度的情況下有比較嚴(yán)重的性能問(wèn)題,以及在多Sliver的場(chǎng)景下會(huì)有滾動(dòng)錯(cuò)誤的功能性問(wèn)題。而在目前閑魚的業(yè)務(wù)中,無(wú)論是搜索結(jié)果還是首頁(yè)的同城頁(yè)面,都會(huì)有混排瀑布流的需求。
所以我們決定參考RecyclerView中StaggeredGridLayoutManager的布局思路實(shí)現(xiàn)一套支持普通流式卡片和橫跨全屏的橫條卡片混排的流式布局,如圖所示:
原理分析與布局流程
其實(shí)瀑布流布局和ListView和GridView一樣,就是按照不同的策略將多個(gè)卡片進(jìn)行尺寸計(jì)算和位置計(jì)算,然后將它們排列到一起,組成一個(gè)超過(guò)一屏,可滾動(dòng)的布局。所以整個(gè)布局策略包括兩個(gè)過(guò)程,首先是對(duì)卡片進(jìn)行尺寸計(jì)算,計(jì)算結(jié)果決定了卡片在滾動(dòng)布局中的大小。然后卡片進(jìn)行位置計(jì)算,計(jì)算結(jié)果決定了卡片在滾動(dòng)布局中的坐標(biāo)。有了大小和坐標(biāo),就可以完成整個(gè)滾動(dòng)容器的布局。下面我會(huì)對(duì)網(wǎng)格布局(GridView)和瀑布流布局(FlowView)的布局策略進(jìn)行一個(gè)對(duì)比,讓大家能更清楚的了解布局過(guò)程的細(xì)節(jié)。
Flutter中網(wǎng)格布局整個(gè)布局的源碼都在flutter/lib/src/rendering/sliver_grid.dart
的performLayout方法中,我們下面跟著源碼來(lái)分析一下整個(gè)布局流程。感興趣的同學(xué)也可以結(jié)合源碼食用本文,風(fēng)味更佳。
網(wǎng)格布局
尺寸計(jì)算過(guò)程
我們先來(lái)分析一下網(wǎng)格布局的卡片尺寸計(jì)算過(guò)程。這是一個(gè)GridView的常用初始化參數(shù),我省略了一些和尺寸計(jì)算無(wú)關(guān)的參數(shù)。
GridView.count({ @required int crossAxisCount, double childAspectRatio = 1.0, })
影響布局的參數(shù)其實(shí)就是crossAxisCount(列數(shù))和childAspectRatio(卡片縱橫比)。有了這兩個(gè)參數(shù)其實(shí)卡片的尺寸就很好計(jì)算了,首先先用crossAxisCount來(lái)對(duì)屏幕寬度進(jìn)行等分,確定卡片的寬度,然后我們?cè)俑鶕?jù)這個(gè)childAspectRatio參數(shù)來(lái)計(jì)算得到卡片的高度。網(wǎng)格布局的卡片尺寸就可以確定下來(lái)了。計(jì)算過(guò)程如圖所示:
位置計(jì)算過(guò)程
在端側(cè),因?yàn)橐粋€(gè)滾動(dòng)容器中的卡片數(shù)量可能會(huì)非常大,所以我們不可能對(duì)所有的卡片都進(jìn)行布局,內(nèi)存和運(yùn)算時(shí)間都是無(wú)法接受的。我們只會(huì)布局在屏幕中以及緩存區(qū)里的卡片,之外的卡片我們會(huì)進(jìn)行回收。等用戶向下滑動(dòng)的時(shí)候,把屏幕下方的卡片創(chuàng)建并布局,然后把已經(jīng)劃出屏幕的卡片進(jìn)行回收。向上滑動(dòng)的過(guò)程也是一樣。所以我們會(huì)對(duì)從上到下和從下到上的位置計(jì)算過(guò)程進(jìn)行分析。
我們先分析從上到下布局的過(guò)程。對(duì)于網(wǎng)格布局來(lái)說(shuō),每一個(gè)卡片的寬度和高度都是在位置計(jì)算流程開始之前就可以提前計(jì)算得出的。我們暫且把每個(gè)卡片的左上角叫做布局坐標(biāo)點(diǎn),我們來(lái)分析一下網(wǎng)格布局中這個(gè)坐標(biāo)如何計(jì)算得出。
我們先來(lái)計(jì)算一下縱坐標(biāo),我們用卡片的index對(duì)crossAxisCount進(jìn)行整除,然后再用結(jié)果乘上卡片的高度,就可以得到卡片的縱坐標(biāo)了。
對(duì)于橫坐標(biāo),我們已經(jīng)根據(jù)crossAxisCount來(lái)對(duì)屏幕寬度進(jìn)行了等分,那么每個(gè)卡片的橫坐標(biāo)就很容易得到了,我們用卡片的index對(duì)crossAxisCount進(jìn)行整除取余,這樣就能得到卡片在某一行中的順序(即第幾列),然后再乘上卡片的寬度,這樣就可以得到卡片的橫坐標(biāo)了。
例如列數(shù)為2,卡片寬度和高度都為100的一個(gè)網(wǎng)格布局,那么第四個(gè)卡片(index為3)的橫坐標(biāo)為(3%2)×100為1,縱坐標(biāo)為 (3~/ 2)×100為100,所以坐標(biāo)為(100,100)。
計(jì)算過(guò)程如圖所示:
整個(gè)布局關(guān)鍵源碼如下:
// 卡片尺寸計(jì)算 final double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1); final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio; // 卡片坐標(biāo)計(jì)算 SliverGridGeometry getGeometryForChildIndex(int index) { final double crossAxisStart = (index % crossAxisCount) * crossAxisStride; //橫坐標(biāo) return SliverGridGeometry( scrollOffset: (index ~/ crossAxisCount) * mainAxisStride, //縱坐標(biāo) crossAxisOffset: _getOffsetFromStartInCrossAxis(crossAxisStart), mainAxisExtent: childMainAxisExtent, crossAxisExtent: childCrossAxisExtent, ); } // 對(duì)卡片進(jìn)行遍歷布局 for (int index = indexOf(firstChild) - 1; index >= firstIndex; --index) { final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index); //獲取尺寸和位置信息 final RenderBox child = insertAndLayoutLeadingChild( gridGeometry.getBoxConstraints(constraints), ); //使用計(jì)算好的尺寸信息來(lái)限制卡片大小 final SliverGridParentData childParentData = child.parentData; childParentData.layoutOffset = gridGeometry.scrollOffset; //卡片的縱軸坐標(biāo)賦值 childParentData.crossAxisOffset = gridGeometry.crossAxisOffset; // 卡片的橫軸坐標(biāo)賦值 assert(childParentData.index == index); trailingChildWithLayout ??= child; trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset); }
由此可見(jiàn),網(wǎng)格布局中,每個(gè)卡片的位置坐標(biāo)跟index是有一一對(duì)應(yīng)關(guān)系的。所以無(wú)論是向下滾動(dòng)對(duì)后面的卡片進(jìn)行布局,還是向上滾動(dòng)對(duì)前面的卡片進(jìn)行布局。都使用這個(gè)策略就可以得出所有卡片的坐標(biāo)。
瀑布流布局
尺寸計(jì)算過(guò)程
然后我們對(duì)瀑布流布局的卡片尺寸計(jì)算過(guò)程進(jìn)行分析,反推出我們需要傳入的初始化參數(shù)。首先,我們需要考慮到在瀑布流布局中一共有兩種卡片,一種是寬度由屏幕寬度被布局列數(shù)均分的普通卡片,另一種是寬度充滿整個(gè)屏幕的特殊卡片,我們后續(xù)叫它橫條卡片。我們會(huì)分別對(duì)這兩種卡片進(jìn)行尺寸計(jì)算。
普通卡片
首先對(duì)于普通卡片來(lái)說(shuō),卡片的尺寸寬度和網(wǎng)格布局中的卡片一樣,是由列數(shù)和屏幕寬度決定的,所以我們同樣需要crossAxisCount這個(gè)參數(shù)。寬度確定之后,我們需要確定卡片的高度。在瀑布流布局中,每個(gè)卡片的高度是不同的,這也是瀑布流布局和網(wǎng)格布局最大的區(qū)別。所以我們其實(shí)可以由每個(gè)卡片自己決定自己的高度,也就是我們不需要在布局初始化的時(shí)候傳入類似childAspectRatio這樣影響卡片的參數(shù)。不過(guò)我們?cè)趯?shí)際的業(yè)務(wù)場(chǎng)景中,通常會(huì)對(duì)某些特殊位置的卡片進(jìn)行特殊的高度設(shè)置,例如兩列流中橫條卡片上面的兩個(gè)卡片,UED會(huì)有保證這兩個(gè)卡片的底部位置一致的需求,不然就會(huì)造成卡片之間的裂隙,影響觀感。所以我們需要一個(gè)定義了一個(gè)方法參數(shù)mainAxisExtentBuilder。
typedef double IndexedMainAxisExtentBuilder(int index);
這是一個(gè)返回值為double的方法參數(shù),瀑布流在布局的時(shí)候會(huì)根據(jù)index嘗試獲取開發(fā)者在這個(gè)方法中的返回值,如果這個(gè)返回值為null,就用卡片自己內(nèi)部的布局來(lái)決定卡片高度,反之就用這個(gè)返回值來(lái)決定卡片高度。計(jì)算過(guò)程如圖所示:
橫條卡片
橫條卡片在高度的確定流程上是和普通卡片一致的,只是橫條卡片的寬度總是和屏幕寬度一致,不受crossAxisCount限制。計(jì)算過(guò)程如圖所示:
所以我們只需要在布局過(guò)程中能夠區(qū)分這兩種卡片,就可以用不同的策略對(duì)它們的尺寸進(jìn)行計(jì)算。類似于mainAxisExtentBuilder,我們定義了一個(gè)IndexedFullSpanBuilder參數(shù)。
typedef bool IndexedFullSpanBuilder(int index);
這是一個(gè)返回值為bool的方法參數(shù),瀑布流在布局的時(shí)候會(huì)根據(jù)index嘗試獲取開發(fā)者在這個(gè)方法中的返回值,如果這個(gè)返回值為null或者false,就使用普通卡片的寬度計(jì)算策略,反之就使用橫條卡片的寬度計(jì)算策略。
所以我們就定義好了瀑布流布局初始化中確定布局的三個(gè)參數(shù)。
FlowView.count({ @required int crossAxisCount, IndexedFullSpanBuilder fullSpanBuilder, IndexedMainAxisExtentBuilder mainAxisExtentBuilder, })
這樣我們就能夠計(jì)算出布局中每一個(gè)卡片的尺寸了,接下來(lái)我們只需要再確定卡片左上角的坐標(biāo),這樣就可以完成卡片的布局了。
位置計(jì)算過(guò)程
對(duì)于瀑布流來(lái)說(shuō),位置計(jì)算過(guò)程會(huì)比網(wǎng)格布局復(fù)雜得多,我們先來(lái)分析一下從上到下布局的過(guò)程。之前我們說(shuō)過(guò),在混排瀑布流布局中會(huì)有兩種卡片,橫條卡片和普通卡片。我們希望卡片的布局中盡量沒(méi)有間隙。
所以對(duì)于普通卡片來(lái)說(shuō),卡片的縱坐標(biāo)計(jì)算過(guò)程是這樣的。我們需要在已經(jīng)完成布局的卡片中進(jìn)行查找,找到其中縱坐標(biāo)+卡片高度(即卡片bottom縱坐標(biāo))值最小的卡片,我們把這張卡片叫做最低卡片。然后把下一張卡片布局在最低卡片的正下方,所以下一張卡片的縱坐標(biāo)就是最低卡片的縱坐標(biāo)+卡片高度。因?yàn)樾枰季衷谧畹涂ㄆ恼路?,所以橫坐標(biāo)就直接和最低卡片的橫坐標(biāo)保持一致即可。
對(duì)于橫條卡片來(lái)說(shuō),因?yàn)樗膶挾瓤偸呛推聊粚挾纫恢?,所以我們只需要?jì)算它的縱坐標(biāo)。它的橫坐標(biāo)永遠(yuǎn)是0,他的縱坐標(biāo)和普通卡片剛好相反,需要在已經(jīng)完成布局的卡片中進(jìn)行查找,找到其中縱坐標(biāo)+卡片高度(即卡片bottom縱坐標(biāo))值最大的卡片,我們把這張卡片叫做最高卡片。然后把橫條卡片布局在這張最高卡片下面,否則這張橫條卡片會(huì)遮住其他卡片。在這里我們根據(jù)列數(shù)生成一個(gè)初始值都為0的縱坐標(biāo)列表,每布局一個(gè)卡片就把該列的offset加上卡片的高度。
計(jì)算過(guò)程如圖所示:
而從下到上的布局過(guò)程,瀑布流和GridView和ListView都不太一樣,ListView,上一個(gè)卡片的位置可以由下一個(gè)卡片布局位置來(lái)確定,往上滾動(dòng)的時(shí)候,我們只用把卡片布局在最上面的卡片上面就可以了,GridView直接根據(jù)index就可以完成計(jì)算了,瀑布流比較特殊,因?yàn)榭ㄆ牟季忠蕾囉谒厦娴目ㄆ牟季中畔?,無(wú)法通過(guò)后一個(gè)卡片的布局信息推斷出前一個(gè)卡片的布局。在這里,一般有兩種處理方式。
維護(hù)一個(gè)index和crossAxisIndex一一對(duì)應(yīng)的Map關(guān)系表
目前RecyclerView和WaterFallFlow是采用這種方式的,在用戶向下滑動(dòng)時(shí),正常布局,然后記錄下每張卡片屬于哪一列。然后在用戶向上滑動(dòng)時(shí),對(duì)即將進(jìn)行布局的卡片,先通過(guò)這個(gè)關(guān)系表得到它屬于哪一列,然后將它布局在這一列最上面卡片的上方,這樣就可以保證卡片的布局對(duì)于用戶來(lái)說(shuō)始終是一致的。但是這樣的方式在混排瀑布流中,需要對(duì)橫條卡片做特殊處理,因?yàn)闄M條卡片的上一張卡片不一定和橫條卡片在布局上是緊貼著的,可能會(huì)有間隙。所以我們還需要記錄橫條卡片跟上一張卡片的間隙,布局的時(shí)候再加上這個(gè)間隙再布局,這樣才能保證正確布局。
使用分頁(yè)思想,始終從上到下進(jìn)行布局。
FlutterStaggeredGridView采用的就是這種方式,而我們實(shí)現(xiàn)的混排瀑布流也使用了這樣的思路。我們?cè)O(shè)定一個(gè)高度PageSize,按照這個(gè)高度給整個(gè)瀑布流布局進(jìn)行分頁(yè),然后維護(hù)一個(gè)pageIndex和pageInfo的對(duì)應(yīng)表,每一頁(yè)里記錄著自己的mainAxisOffsets,以及的firstChildIndex。
第一頁(yè)的mainAxisOffsets很顯然是一個(gè)長(zhǎng)度為crossAxisCount,值為0的列表。然后從上到下布局時(shí),不斷更新這個(gè)mainAxisOffsets,例如第一頁(yè)在第一列布局了第一個(gè)高度為100的普通卡片,則mainAxisOffsets更新為{100,0}。然后在第二列布局了第二個(gè)高度為150的普通卡片,則mainAxisOffsets更新為{100,150}。后續(xù)我們布局了一個(gè)高度為200的橫條卡片,則mainAxisOffsets更新為{350,350}。然后橫條卡片和第一張卡片之間會(huì)有一個(gè)50的間隙,這個(gè)mainAxisOffsets就是下一張卡片布局的起始點(diǎn)。然后當(dāng)有mainAxisOffsets都超過(guò)PageSize時(shí),我們就開始分下一頁(yè)。下一頁(yè)的initialOffsets就是上一頁(yè)的mainAxisOffsets,然后再開始第二頁(yè)的卡片布局。
這樣當(dāng)我們向上滾動(dòng)時(shí),當(dāng)我們需要對(duì)上一個(gè)卡片進(jìn)行布局時(shí),我們就會(huì)從這個(gè)卡片所屬的頁(yè)面的第一個(gè)卡片開始布局,這樣就瀑布流就始終是從上到下布局的。就能保證布局的正確性。
然后我們按照RenderSliverGrid的思路實(shí)現(xiàn)了一個(gè)RenderSliverFlow。整個(gè)布局的關(guān)鍵的源碼如下:
// 卡片坐標(biāo)計(jì)算 SliverFlowGeometry getGeometryForChildIndex(int index,List<double> startOffsets) { bool isFullSpan = _getIsFullSpan(index); //是否是橫條卡片 double maxOffset = startOffsets.reduce(math.max); //最高卡片底部縱坐標(biāo) double minOffset = startOffsets.reduce(math.min); //最低卡片底部縱坐標(biāo) var scrollOffset = minOffset; var crossAxisIndex = startOffsets.indexOf(minOffset); //屬于哪一列 int needCrossAxisCount = isFullSpan ? crossAxisCount : 1; if(isFullSpan){ scrollOffset = maxOffset; crossAxisIndex = 0; } if (reverseCrossAxis) { crossAxisIndex = crossAxisCount - needCrossAxisCount - crossAxisIndex; } var crossAxisOffset = crossAxisIndex * crossAxisStride; var mainAxisExtent = _getChildMainAxisExtent(index); return SliverFlowGeometry( scrollOffset: scrollOffset, //縱坐標(biāo) crossAxisOffset: crossAxisOffset, //橫坐標(biāo) mainAxisExtent: mainAxisExtent, crossAxisExtent: crossAxisStride * needCrossAxisCount - crossAxisSpacing, isFullSpan: isFullSpan, crossAxisIndex: crossAxisIndex, ); }
內(nèi)存回收和性能優(yōu)化
回收機(jī)制
前文中我們提到過(guò),在端側(cè),因?yàn)橐粋€(gè)滾動(dòng)容器中的卡片數(shù)量可能會(huì)非常大,所以我們不可能一次性對(duì)所有的卡片都進(jìn)行布局和繪制,內(nèi)存和運(yùn)算時(shí)間都是無(wú)法接受的。
我們總是希望只布局盡可能少的卡片,我們先來(lái)分析一下最晚可以從哪個(gè)卡片開始布局。從上文我們知道,我們將整個(gè)瀑布流進(jìn)行了分頁(yè),每一頁(yè)包含著多個(gè)卡片,我們記錄著每一頁(yè)的起始o(jì)ffsets,所以我們需要找可見(jiàn)區(qū)域最上方的卡片,把這個(gè)卡片的位置標(biāo)記為firstIndex,然后從這個(gè)卡片所屬的頁(yè)面的第一個(gè)卡片開始布局。然后我們?cè)俜治鲆幌虏季衷谑裁磿r(shí)候結(jié)束,因?yàn)槲覀兦懊娴目ㄆ瑹o(wú)需依賴后面的卡片,所以我們布局到可視區(qū)域之外就可以停止布局了,然后把最后一張卡片的位置標(biāo)記為lastIndex。每一次布局都會(huì)產(chǎn)生一個(gè)firstIndex和lastIndex。
當(dāng)我們往下滑動(dòng)的時(shí)候,我們會(huì)判斷firstIndex屬于哪一頁(yè),這就表明這一頁(yè)此時(shí)在最上方,那對(duì)這一頁(yè)之前的Page里的卡片我們就可以進(jìn)行內(nèi)存回收了。往上滑動(dòng)的時(shí)候,我們把lastIndex之后的卡片全部進(jìn)行回收就好了。
性能優(yōu)化
這樣的分頁(yè)機(jī)制雖然是能夠保證布局的正確性,但是其實(shí)很多情況下,我們都需要布局緩存區(qū)以外的卡片,舉個(gè)極端情況的例子,可見(jiàn)區(qū)域的第一張卡片是屬于某一個(gè)分頁(yè)的最后一張卡片,這個(gè)時(shí)候我們就不得不把這個(gè)分頁(yè)里的全部卡片都進(jìn)行布局。這其實(shí)會(huì)對(duì)滑動(dòng)性能造成一些影響,一開始的設(shè)計(jì)PageSize固定為一個(gè)屏幕的高度,每一屏分一頁(yè)。后來(lái)進(jìn)行了性能優(yōu)化,我們會(huì)根據(jù)大部分瀑布流的卡片高度得到一個(gè)分頁(yè)值,盡量保證每一次分頁(yè)所包含的卡片盡可能就是一行的卡片數(shù)。這樣可見(jiàn)區(qū)域的第一張卡片往往就是這個(gè)分頁(yè)的第一張卡片,這樣一來(lái)就可以減少不必要的布局。
然后我們對(duì)GridView和FlowView進(jìn)行了性能測(cè)試,使用腳本對(duì)兩個(gè)滾動(dòng)容器分別往下滾動(dòng)五次,再滾動(dòng)五次。最后得出性能數(shù)據(jù),然后我們主要關(guān)注兩個(gè)數(shù)據(jù),分別是最大丟幀數(shù)和最差幀耗時(shí),這往往就是最影響體感的兩個(gè)數(shù)據(jù)。通過(guò)根據(jù)平均卡片尺寸高度動(dòng)態(tài)調(diào)整分頁(yè),最后的性能數(shù)據(jù)達(dá)到了盡可能和GridView一致。使用同一機(jī)型,性能測(cè)試數(shù)據(jù)如下:
效果與落地
這是目前使用FlowView完成的一個(gè)Demo工程,支持了Flutter滾動(dòng)體系里的各種功能。scrollController(滾動(dòng)到offset),reverse(逆序排列),scrollDirection(滾動(dòng)方向垂直或水平滾動(dòng))等。
在閑魚工程中,主要在首頁(yè)、搜索結(jié)果頁(yè)等進(jìn)行落地。不過(guò)目前Flutter首頁(yè)在線上只是進(jìn)行了少量的灰度。
總結(jié)與展望
整個(gè)瀑布流目前結(jié)合PowerScrollView進(jìn)行了初步落地,在整個(gè)布局的過(guò)程中,在功能上可擴(kuò)展和優(yōu)化的地方依然存在。
在可擴(kuò)展的功能方面,未來(lái)希望可以在一個(gè)布局中完成不同列數(shù)的混排,例如一個(gè)Sliver中可以有一列、兩列、三列、甚至六列的混排,類似于RecyclerView中的GridLayoutManager。
然后在性能方面,希望之后能夠在布局邏輯中進(jìn)行優(yōu)化,盡可能減少不必要的計(jì)算和布局。能夠在滑動(dòng)中提供更好的體感。
希望官方之后會(huì)對(duì)這樣比較常用的布局進(jìn)行支持,這樣也可以給后面的布局優(yōu)化帶來(lái)思路。
到此這篇關(guān)于詳解Flutter混排瀑布流解決方案的文章就介紹到這了,更多相關(guān)Flutter混排瀑布流內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android編程實(shí)現(xiàn)的EditText彈出打開和關(guān)閉工具類
這篇文章主要介紹了Android編程實(shí)現(xiàn)的EditText彈出打開和關(guān)閉工具類,涉及Android輸入框EditText彈出打開和關(guān)閉功能簡(jiǎn)單實(shí)現(xiàn)技巧,需要的朋友可以參考下2018-02-02Android使用個(gè)推實(shí)現(xiàn)三方應(yīng)用的推送功能
這篇文章主要為大家詳細(xì)介紹了Android使用個(gè)推實(shí)現(xiàn)三方應(yīng)用的推送功能,感興趣的小伙伴們可以參考一下2016-08-08Android中制作進(jìn)度框和環(huán)形進(jìn)度條的簡(jiǎn)單實(shí)例分享
這篇文章主要介紹了Android中制作進(jìn)度框和環(huán)形進(jìn)度條的簡(jiǎn)單實(shí)例分享,環(huán)形進(jìn)度條帶有基本的百分比顯示,需要的朋友可以參考下2016-03-03Android自定義view制作抽獎(jiǎng)轉(zhuǎn)盤
這篇文章主要為大家詳細(xì)介紹了Android自定義view制作抽獎(jiǎng)轉(zhuǎn)盤,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-12-12Android實(shí)現(xiàn)帶列表的地圖POI周邊搜索功能
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)帶列表的地圖POI周邊搜索功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-05-05Android 4.4以上"沉浸式"狀態(tài)欄效果的實(shí)現(xiàn)方法
Android與ios效果互仿早已不是什么稀奇的事,我猜大概這個(gè)效果來(lái)自ios吧,有爭(zhēng)議說(shuō)這種效果不能叫做沉浸式,叫透明狀態(tài)欄更合適,我也感覺(jué)這和沉浸式的含義不太一致。但是大家都這么叫了,那就這樣唄。下面來(lái)一起看看關(guān)于Android 4.4以上"沉浸式"效果的實(shí)現(xiàn)方法。2016-09-09Android實(shí)現(xiàn)層疊卡片式banner
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)層疊卡片式banner,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-10-10