CollapsingToolbarLayout source code interpretation and implementation of folding process custom ceiling RecyclerView | ceiling title bar

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

stateJudgment method
Expanded stateSliding offset == 0
folding stateSliding offset exceeds the maximum slidable range of AppBarLayout
In process statusBetween 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.

Tags: Android

Posted on Tue, 05 Oct 2021 20:22:48 -0400 by stuartbrown20