Introduction to chapter objectives
The original style provided by CollapsingToolbarLayout may not meet the requirements of actual development, because this paper hopes to meet the requirements of development by understanding its internal source code and implementing a custom ceiling Toolbar.
Source code understanding
This part will start from the composition of folding layout and source code analysis
1. Composition of folding layout
The composition of the whole folding layout is to wrap the collapsing toolbarlayout folding layout through AppBarLayout. Collapsing toolbarlayout is a layout of FrameLayout type. In the layout of its sub view, set the folding mode as parallax for the collapsible part, and the folding process will move parallel with it; Set the folding mode to pin for the part requiring ceiling, and the relative global position remains unchanged during the folding process. This part is usually used to set the ceiling layout.
2. Calculation of sliding displacement in folding process
Since the overall layout is wrapped inside AppBarLayout, the maximum sliding range of the folded layout can be confirmed inside, the height of all slidable sub views can be accumulated inside, and then the minimum height after folding can be deducted.
//Calculates the slidable range of all subviews public final int getTotalScrollRange() { if (totalScrollRange != INVALID_SCROLL_RANGE) { return totalScrollRange; } int range = 0; //Accumulates the height of all slidable subviews for (int i = 0, z = getChildCount(); i < z; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); final int childHeight = child.getMeasuredHeight(); final int flags = lp.scrollFlags; if ((flags & LayoutParams.SCROLL_FLAG_SCROLL) != 0) { // We're set to scroll so add the child's height range += childHeight + lp.topMargin + lp.bottomMargin; if (i == 0 && ViewCompat.getFitsSystemWindows(child)) { // If this is the first child and it wants to handle system windows, we need to make // sure we don't scroll it past the inset range -= getTopInset(); } //If folding on exit is set, the sliding range deducts the minimum height after folding, that is, the height of the inner Toolbar if ((flags & LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED) != 0) { // For a collapsing scroll, we to take the collapsed height into account. // We also break straight away since later views can't scroll beneath // us range -= ViewCompat.getMinimumHeight(child); break; } } else { // As soon as a view doesn't have the scroll flag, we end the range calculation. // This is because views below can not scroll under a fixed view. break; } } return totalScrollRange = Math.max(0, range); }
Let's take a look at how the sliding displacement in the Scroll process is transferred. It is invoked in the onNestedScroll method used to solve the nested slider. When y slipping occurs, there is no consumption of Y sliding, indicating that it is in the position of the head content.
@Override public void onNestedScroll( CoordinatorLayout coordinatorLayout, @NonNull T child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, int[] consumed) { if (dyUnconsumed < 0) { //When y-direction sliding occurs, but no consumption of y-direction sliding occurs, it indicates that it is currently in the position of the head content // If the scrolling view is scrolling down but not consuming, it's probably be at // the top of it's content consumed[1] = scroll(coordinatorLayout, child, dyUnconsumed, -child.getDownNestedScrollRange(), 0); } } ... //Calculate sliding displacement Offset final int scroll( CoordinatorLayout coordinatorLayout, V header, int dy, int minOffset, int maxOffset) { return setHeaderTopBottomOffset( coordinatorLayout, header, getTopBottomOffsetForScrollingSibling() - dy, minOffset, maxOffset); }
The detailed code for calculating the sliding displacement is as follows
@Override int setHeaderTopBottomOffset( @NonNull CoordinatorLayout coordinatorLayout, @NonNull T appBarLayout, int newOffset, int minOffset, int maxOffset) { final int curOffset = getTopBottomOffsetForScrollingSibling(); int consumed = 0; if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) { // If we have some scrolling range, and we're currently within the min and max //Make sure newOffset does not exceed the range of maximum and minimum values // offsets, calculate a new offset newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset); if (curOffset != newOffset) { final int interpolatedOffset = appBarLayout.hasChildWithInterpolator() ? interpolateOffset(appBarLayout, newOffset) : newOffset; final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset); //Consume displacement, i.e. dy, and update all variables // Update how much dy we have consumed consumed = curOffset - newOffset; // Update the stored sibling offset offsetDelta = newOffset - interpolatedOffset; if (!offsetChanged && appBarLayout.hasChildWithInterpolator()) { // If the offset hasn't changed and we're using an interpolated scroll // then we need to keep any dependent views updated. CoL will do this for // us when we move, but we need to do it manually when we don't (as an // interpolated scroll may finish early). coordinatorLayout.dispatchDependentViewsChanged(appBarLayout); } //Inform all observers of the change of sliding displacement offset // Dispatch the updates to any listeners appBarLayout.onOffsetChanged(getTopAndBottomOffset()); // Update the AppBarLayout's drawable state (for any elevation changes) updateAppBarLayoutDrawableState( coordinatorLayout, appBarLayout, newOffset, newOffset < curOffset ? -1 : 1, false /* forceJump */); } } else { // Reset the offset delta offsetDelta = 0; } return consumed; } ...
In the method of notifying the observer, we see that its type is BaseOnOffsetChangedListener. We can implement it by referring to the OffsetUpdateListener (its subclass) in AppBarLayout. This idea is basically referred to in the following definition
3. Realization of Title Scaling in sliding process
We can see that the title set for CollapsingToolbarLayout in the effect can be scaled and moved during sliding, and its internal drawn text is placed in the CollapsingTextHelper class
//Create a Bitmap containing text and store it in the global variable expandedTitleTexture private void ensureExpandedTexture() { if (expandedTitleTexture != null || expandedBounds.isEmpty() || TextUtils.isEmpty(textToDraw)) { return; } calculateOffsets(0f); textureAscent = textPaint.ascent(); textureDescent = textPaint.descent(); //Calculate the width and height of the text to be drawn final int w = Math.round(textPaint.measureText(textToDraw, 0, textToDraw.length())); final int h = Math.round(textureDescent - textureAscent); if (w <= 0 || h <= 0) { return; // If the width or height are 0, return } //Create a Bitmap to lay out the Canvas and draw text expandedTitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); Canvas c = new Canvas(expandedTitleTexture); //Draws text within a specific box c.drawText(textToDraw, 0, textToDraw.length(), 0, h - textPaint.descent(), textPaint); if (texturePaint == null) { // Make sure we have a paint texturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); } }
The source code of drawing canvas is as follows
public void draw(@NonNull Canvas canvas) { final int saveCount = canvas.save(); if (textToDraw != null && drawTitle) { float x = currentDrawX; float y = currentDrawY; final boolean drawTexture = useTexture && expandedTitleTexture != null; final float ascent; final float descent; //When you need to draw text, you need to scale its position if (drawTexture) { ascent = textureAscent * scale; descent = textureDescent * scale; } else { ascent = textPaint.ascent() * scale; descent = textPaint.descent() * scale; } if (DEBUG_DRAW) { // Just a debug tool, which drawn a magenta rect in the text bounds canvas.drawRect( currentBounds.left, y + ascent, currentBounds.right, y + descent, DEBUG_DRAW_PAINT); } if (drawTexture) { y += ascent; } //Zoom the canvas if (scale != 1f) { canvas.scale(scale, scale, x, y); } //Draw the desired text if (drawTexture) { // If we should use a texture, draw it instead of text //Based on Bitmap drawing, this method is used when version 18 or below is reduced canvas.drawBitmap(expandedTitleTexture, x, y, texturePaint); } else { //Draw text directly, except for the above canvas.drawText(textToDraw, 0, textToDraw.length(), x, y, textPaint); } }
Custom ceiling RecyclerView effect
In fact, the customized AppBarLayout can achieve the following effects, as shown below. When the title bar is expanded, the large album cover can be displayed, and the reduced album cover will not be displayed; In the process of sliding and folding, the reduced album is gradually displayed, and the cover of the large album is gradually retracted; In the fully folded state, the reduced album is fully displayed, and the large album cover is stowed.
When expanding
Folding
After folding
Overall video
Custom code description
The goal of customization is to display the reduced picture of album map in the Toolbar at the top when it is folded. First, you need to be able to monitor the sliding displacement of AppBarLayout and judge whether it is in the expanded state, folded state or in process state. The basic rules for judging are as follows
state | Judgment method |
---|---|
Expanded state | Sliding offset == 0 |
folding state | Sliding offset exceeds the maximum slidable range of AppBarLayout |
In process status | Between the above two states |
Define the implementation class of sliding displacement monitoring by implementing AppBarLayout.OnOffsetChangedListener, which encapsulates the addition of a reduced album picture to the Toolbar, and set the ImageView to display, not display and transparency change in different states to achieve the effect of the example
/** * Monitor whether the Toolbar is in the expanded state or folded state. It is used to set the display of miniature pictures of the Toolbar in the folded state */ public class OffsetListener implements AppBarLayout.OnOffsetChangedListener { //Zoom out status image private ImageView mImageView; //Toobar object private Toolbar mToolbar; private static final String TAG = "OffsetListener"; @Override public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) { Log.d(TAG,"offset = "+verticalOffset); //It is unfolded when no sliding occurs boolean expandStatus = (verticalOffset == 0); //When the sliding displacement exceeds the maximum sliding distance of the AppBar, it is regarded as a folded state boolean collapseStatus = (Math.abs(verticalOffset) >= appBarLayout.getTotalScrollRange()); if(expandStatus){ hideImageView(); }else if(collapseStatus){ showImageView(); }else { boolean almostCollapseStatus = (Math.abs(verticalOffset) >= (appBarLayout.getTotalScrollRange()/2)); if(almostCollapseStatus){ //Folding state showImageView(); int alpha = 255 * Math.abs(verticalOffset) / appBarLayout.getTotalScrollRange(); setAlpha(alpha); }else { hideImageView(); } } } //Initialize zoom out status image private void addImageView(){ if(null == mImageView){ mImageView = new ImageView(mToolbar.getContext()); } mImageView.setLayoutParams(new ViewGroup.LayoutParams(50,50)); mImageView.setImageResource(R.drawable.picture_example); Glide.with(mImageView).load(R.drawable.picture_example).apply(RequestOptions.circleCropTransform()).into(mImageView); mToolbar.addView(mImageView); //The initial state is set so that the thumbnail image is not displayed mImageView.setVisibility(View.INVISIBLE); } //Set image transparency private void setAlpha(int alpha){ if(null == mImageView){ return; } mImageView.setAlpha(alpha); } //Display zoom out status image private void showImageView(){ if(null == mImageView){ return; } mImageView.setVisibility(View.VISIBLE); } //Hide reduced image private void hideImageView(){ if(null == mImageView){ return; } mImageView.setVisibility(View.INVISIBLE); } public void addToolbar(Toolbar toolbar){ this.mToolbar = toolbar; addImageView(); } public void removeToolbar(Toolbar toolbar){ this.mImageView = null; this.mToolbar = null; } }
Then register the listener through the AppBarLayout object
//Collapsible title block private AppBarLayout mAppBar; mAppBar = findViewById(R.id.app_bar); mOffsetListener = new OffsetListener(); //Note that this should be done before calling setSupportActionBar mOffsetListener.addToolbar(mToolbar); setSupportActionBar(mToolbar); mAppBar.addOnOffsetChangedListener(mOffsetListener);
You can also define the corresponding return logic for the return key in the Toolbar, and its basic code is as follows
//Set Toobar key monitoring private void initListener(){ mToolbar.setNavigationOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Toast.makeText(MainActivity.this,"Click Toolbar" ,Toast.LENGTH_SHORT).show(); } }); }
To avoid memory leaks, finally, note that listeners need to be removed when the view is reclaimed
@Override protected void onDestroy() { mOffsetListener.removeToolbar(mToolbar); super.onDestroy(); }
Learning experience
This article illustrates the folding process by adding a reduced album map to the Toolbar through customization. In the actual development needs, we still have many diversified requirements. For example, we need to implement the folded control, not the big album image ImageView here, but other controls. How to implement it. The subsequent expansion idea is still to add the layout to be folded in the collapsing Toolbar layout sub view of the layout layout. Pay attention to setting app:layout_collapseMode="parallax". CollapsingToolbarLayout is a subclass of FrameLayout. Define the required attributes according to the layout requirements of FrameLayout, and then define them with appropriate size and spacing to achieve any effect you need. The basic layout style is as follows. You can set any layout you need to add before the collapsible ImageView of the example, and set the property in the layout to collapsible.