Статьи

Как сделать экран профиля Google Plus

Здесь мы увидим способ создания экрана профиля Google Plus. Я нахожу этот экран довольно интересным (хотя он имеет некоторые незначительные ошибки), и хотел попытаться найти способ сделать это.

Примечание : я не буду рассказывать о странной нижней панели, которую вы должны использовать, если хотите создать новый пост. Я ненавижу этот «быстрый возврат» бар

Я разделю этот пост на три части:

  • Иерархия представлений
  • Эффект параллакса
  • Липкие вкладки (вкладки о / сообщения / видео, …)

Иерархия взглядов

Контейнер

Кажется очевидным, у нас есть список внизу экрана. Вопрос: что это за заголовок?

Я вижу 2 варианта здесь:

  1. Заголовок не является заголовком списка. Это означает, что есть FrameLayout (или что-то еще), содержащий заголовок и список (с большим заполнением и clipToPaddingустановленным в false).
  2. Заголовок включен в сетку как HeaderView.

На самом деле, есть некоторые намеки, которые указывают нам, что второй является более правдоподобным:

  • Мы можем прокрутить, коснувшись в любом месте заголовка. Хм хорошо, но мы также могли бы с пользовательскими взглядами. Мы, вероятно , придется изменить поведение по умолчанию вкладки с помощью переопределения onInterceptTouchEventи onTouchEventметодов.
  • Вверху списка есть краевой эффект. Тем не менее, мы могли бы поместить listView поверх заголовка, чтобы увидеть эффект края. Липкие вкладки также будут видны, потому что они нарисованы после списка draw. (См. Часть 3).
  • Я думаю, что это проще сделать таким образом (особенно если вы хотите быть совместимыми до ICS).

Основной xml должен выглядеть так:

<GridView .../>

Хм, хорошо, это так StaggeredGridView, но это в основном то же самое. Не вините меня, если после этого я назову это ListView или GridView.

Заголовок

Посмотрите на оверрейд. Заголовок изображения светло-красный. Это означает, что пиксели отрисовываются четыре раза. Уч. Извините, но я думаю, он должен быть синим Я имею в виду, есть фон деятельности, и … что еще? Очевидно, здесь есть проблема с перфорированием.

Примечание : если вы хотите узнать больше об оверте, прочитайте этот пост от Ромена Гая .

Вот пример иерархии, которую вы могли бы иметь:

<!-- in a real application you should use styles and dimens... -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/header_imageview"
        android:layout_width="match_parent"
        android:layout_height="150dp"
        android:scaleType="centerCrop"
        android:adjustViewBounds="true"
         />

    <LinearLayout
        android:id="@+id/informations_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:layout_below="@+id/header_imageview"
        android:gravity="center"
        android:paddingTop="32dp"
        android:paddingBottom="32dp">


        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/header_name"
            android:textStyle="bold"
            android:textSize="17dp" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:gravity="center"
            android:textColor="#FF707070"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:textColor="#FF909090" />

    </LinearLayout>

    <HorizontalScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:scrollbars="none"
        android:layout_below="@+id/informations_container"
        android:background="#FFF0F0F0">

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal">

            <!-- Here are the tabs, Just TextViews with a selector and a clickListener... -->
            <!-- There is also two views at each edge with a gradient drawable making the fade effect. But I find this pretty ugly. -->

        </LinearLayout>
    </HorizontalScrollView>

  <!-- cheat code: negative margin -->
    <ImageView
        android:layout_width="75dp"
        android:layout_height="75dp"
        android:src="@drawable/avatar"
        android:layout_centerHorizontal="true"
        android:layout_above="@+id/informations_container"
        android:layout_marginBottom="-16dp" />

</RelativeLayout>

Это в основном все, что касается иерархии представлений.

Примечание: Если вы хотите знать , как сделать закругленный аватар, я настоятельно рекомендую вам прочитать отличный пост по Evelio Таразон Касерес .

Эффект параллакса

На заголовке изображения есть хороший эффект параллакса. В основном, когда вы прокручиваете свой список вверх, изображение должно прокручиваться 0.5 * translationYвместо 1 * translationY. Вы не можете отменить перевод в родительском, потому что это часть GridView, поэтому вам придется переводить изображение с помощью -0.5 * translationY.

Просто установите OnScrollListenerтак:

@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
    int totalItemCount) {
  if(visibleItemCount == 0) return;
  if(firstVisibleItem != 0) return;

  mImageView.setTranslationY(-mListView.getChildAt(0).getTop() / 2);

}

Это будет работать. НО:

  1. Вы не поддерживаете предварительный API14 (нам все равно, но приложение G + поддерживает)
  2. Если вы используете TranslateAnimation для устройств до ICS, у вас возникнет проблема с перерасходом.

Вам просто нужно нарисовать видимую часть растрового изображения. Давайте создадим собственный ImageView, определим setParallaxTranslationметод и переопределим drawметод:

public void setCurrentTranslation(int currentTranslation) {
  mCurrentTranslation = currentTranslation;
  invalidate();
}

@Override
public void draw(Canvas canvas) {
  canvas.save();
  canvas.translate(0, -mCurrentTranslation / 2)  ;
  super.draw(canvas);
  canvas.restore();
}

Затем измените вызов с mImageView.setTranslationY(-mListView.getChildAt(0).getTop() / 2);наmImageView.setCurrentTranslation(mListView.getChildAt(0).getTop());

ДОПОЛНИТЕЛЬНО 1 :

Вы можете добавить, ColorFilterчтобы добавить хороший эффект на ImageView:

public void setCurrentTranslation(int currentTranslation) {
    mCurrentTranslation = currentTranslation;
    float ratio =  -mCurrentTranslation / (float)getHeight();
    int color = Color.argb((int) (fMAX_COLORFILTER_ALPHA * ratio), 0, 0, 0);
    setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
}

При этом ImageView будет темнее при прокрутке.

EXTRA 2 : Если вы хотите сделать более параллакса вещи на своих приложениях, посмотрите на Paralloid библиотеки по Christopher Jenkins .

Липкий заголовок

Во-первых, я хотел бы поблагодарить Флавиана Лорана, который мне очень помог в этой части!

Примечание : вкладки являются липкими и появляются над listView. Это означает, что это представление рисуется поверх GridView.

Есть много способов прикрепить заголовок, и самый простой — это поддержка только устройств API14 +, что дает доступ к таким операциям с представлениями, как View#setTranslationX/Y.

1-е решение: вкладки не являются частью заголовка

Это, кажется, самый простой способ сделать это, но это не очень хорошо сработает на устройствах preICS (кого это волнует?).

Ваша деятельность состоит из FrameLayout, содержащего ListViewи закрепленный HorizontalScrollView.

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ListView
        android:id="@+id/listview"
        android:layout_height="match_parent"
        android:layout_width="match_parent" />

    <include
        layout="@layout/stickyheader"
        android:id="@+id/stickyheader" />

</FrameLayout>

ListViewБудет содержать заголовок, содержащий фиктивный вид. Этот вид должен иметь ту же высоту, что и ваши вкладки.

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fr.castorflex.android.googleplusprofilepageapp.ParallaxImageView ... />

    <LinearLayout ... >
        <TextView ... />
        <TextView ... />
        <TextView ... />
    </LinearLayout>

  <!-- dummy view; the sticky header will be positionned here-->
    <View
        android:layout_width="match_parent"
        android:layout_height="48dp"/>

    <ImageView ... />

</RelativeLayout>

Теперь единственное, что вам нужно, это перевести ваш липкий вид:

mListView = (StickyListView) findViewById(R.id.listview);
mStickyHeader = findViewById(R.id.header);
mHeader = LayoutInflater.from(this).inflate(R.layout.header, mListView, false);
mListView.addHeaderView(mHeader);


mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
    //...

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        if (visibleItemCount == 0) return;

        int top = mHeader.getTop();
        int tabsHeight = mStickyHeader.getMeasuredHeight();
        int headerViewHeight = mHeader.getMeasuredHeight();
        int delta = headerViewHeight - tabsHeight;

        mStickyHeader.setTranslationY(Math.max(0, delta + top));
    }
});

Если вы хотите сделать его совместимым с preICS, не используйте NineOldAndroids. NineOldAndroids — это оболочка, которая будет использовать анимацию preICS. Это означает, что ничья представления будет переведена, но события касания не будут.

Например, представьте, что у вас есть кнопка в 0,0, и вы переводите ее в 150,150 с помощью TranslateAnimation. Вы заметите, что если вы нажмете 150 150, кнопка не будет нажата, но если вы нажмете 0,0, событие будет вызвано.

Единственный способ переместить ваш взгляд на preICS — играть с полями. НО вам придется звонить requestLayout()каждый раз, когда вы устанавливаете новое значение topMargin. Если вы сделаете это, вы заметите лаги. Перейдите по этой ссылке для примера

2-е решение: динамически изменить родителя вашего представления stickyHeader

В этом решении вкладки являются частью заголовка ListView до тех пор, пока нам не нужно их прикрепить. Это решение лучше для устройств preICS, но requestLayout () будет вызываться каждый раз, когда мы меняем родителя (когда мы удаляем представление и затем добавляем его в FrameLayout, requestLayout()он будет вызываться дважды).

Вот новый вид заголовка:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fr.castorflex.android.googleplusprofilepageapp.ParallaxImageView ... />

    <LinearLayout ... >
        <TextView ... />
        <TextView ... />
        <TextView ... />
    </LinearLayout>

  <!-- we add one more container to facilitate the add/remove view -->
    <FrameLayout android:id="@+id/tabs_container"
        android:layout_height="48dp"
        android:layout_width="match_parent"
        android:layout_below="@+id/informations_container">

      <HorizontalScrollView
          android:id="@+id/horizontalscrollview"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:scrollbars="none"
          >

          <LinearLayout
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:orientation="horizontal">

              <!-- tabs here -->

          </LinearLayout>
      </HorizontalScrollView>
    </FrameLayout>

    <ImageView ... />

</RelativeLayout>

Держите логическое значение, чтобы знать, придерживались ли вы своего представления ( mIsHeaderStickedздесь).

mIsHeaderSticked = false;

mMainView = (ViewGroup) findViewById(R.id.main_view);
mHeader = LayoutInflater.from(this).inflate(R.layout.header, mListView, false);
mHorizontalScrollViewContainer = (ViewGroup) mHeader.findViewById(R.id.tabs_container);
mHorizontalScrollView = mHeader.findViewById(R.id.horizontalscrollview);
mListView.addHeaderView(mHeader);

//...

mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
    //...

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        if (visibleItemCount == 0) return;

        boolean shouldStick = false;
        if (view.getChildAt(0) != mHeader) shouldStick = true;
        else {
            int top = mHeader.getTop();
            int tabsHeight = mHorizontalScrollView.getMeasuredHeight();
            int headerViewHeight = mHeader.getMeasuredHeight();
            int delta = headerViewHeight - tabsHeight;

            if (delta + top < 0) shouldStick = true;
        }

        if (shouldStick && !mIsSticked) {
            mIsSticked = true;
            ((ViewGroup) mHorizontalScrollView.getParent()).removeView(mHorizontalScrollView);
            mMainView.addView(mHorizontalScrollView);
        } else if (!shouldStick && mIsSticked) {
            mIsSticked = false;
            ((ViewGroup) mHorizontalScrollView.getParent()).removeView(mHorizontalScrollView);
            mHorizontalScrollViewContainer.addView(mHorizontalScrollView);
        }

    }
});

3-е решение: просто нарисуйте HorizontalScrollView и захватить сенсорные события

Это решение прекрасно, если вам нужно только нарисовать представление без состояния , но его может быть довольно сложно реализовать, если у вас есть представления, с которыми вы можете взаимодействовать.

Идея здесь состоит в том, чтобы сохранить вкладки в заголовке ListView, но когда нам нужно его прикрепить, они ListViewбудут рисовать вкладки над списком.

mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
    //...

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        if (visibleItemCount == 0) return;

        boolean shouldStick = false;
        if (view.getChildAt(0) != mHeader) shouldStick = true;
        else {
            int top = mHeader.getTop();
            int tabsHeight = mHorizontalScrollView.getMeasuredHeight();
            int headerViewHeight = mHeader.getMeasuredHeight();
            int delta = headerViewHeight - tabsHeight;

            if (delta + top < 0) shouldStick = true;
        }

        //sticky header
        if (shouldStick)
            mListView.setViewToDraw(mHorizontalScrollViewContainer);
        else
            mListView.setViewToDraw(null);
    }
});

В вашем пользовательском ListView предоставьте setViewToDrawметод:

public void setViewToDraw(View viewToDraw) {
    if (mViewToDraw == viewToDraw) {
      return;
    }

    mViewToDraw = viewToDraw;
    invalidate();
}

и переопределить dispatchDraw:

@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    if (mViewToDraw == null) return;

    mViewToDraw.draw(canvas);
}

Ваши вкладки должны закрепиться, но вы не можете с ними взаимодействовать. Вот что я сделал, но, к сожалению, это не очень хорошо работает, так как я не могу заставить его работать … Если у вас есть идеи, как его улучшить, пожалуйста, не стесняйтесь обращаться ко мне .

Сначала создайте перечисление с различными состояниями:

private enum TouchState {NONE, TOUCHING_HEADER}
private TouchState mTouchState = TouchState.NONE;

Затем переопределите dispatchTouchEvent

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
  if (mViewToDraw == null) {
    return super.dispatchTouchEvent(ev);
  }

  int bottom = mViewToDraw.getMeasuredHeight();
  boolean captured = false;
  boolean invalidate = false;

  switch (ev.getAction()) {
    case MotionEvent.ACTION_DOWN:
      if (ev.getY() <= bottom) {
        mTouchState = TouchState.TOUCHING_HEADER;
        invalidate = true;
        captured = mViewToDraw.dispatchTouchEvent(ev);
      }
      break;
    case MotionEvent.ACTION_CANCEL:
    case MotionEvent.ACTION_UP:
      if (mTouchState == TouchState.TOUCHING_HEADER) {
        mTouchState = TouchState.NONE;
        invalidate = true;
        captured = mViewToDraw.dispatchTouchEvent(ev);
      }
      break;
    default:
      if (mTouchState == TouchState.TOUCHING_HEADER) {
        invalidate = true;
        captured = mViewToDraw.dispatchTouchEvent(ev);
      }
  }

  if (invalidate) {
    invalidate(0, 0, getWidth(), bottom);
  }

  if (captured) {
    return true;
  }

  return super.dispatchTouchEvent(ev);
}

Если вы хотите использовать это решение, вам явно нужно улучшить мою реализацию.

Четвертое решение: продублируйте вкладки и сделайте их видимыми / невидимыми

Это не очень хороший способ реализовать липкие вкладки, но это будет работать, и вы избежите звонков requestLayout()

Вам нужно будет скопировать и вставить представление вкладок в xml заголовка и в xml основного представления.

mListView.setOnScrollListener(new AbsListView.OnScrollListener() {

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        if (visibleItemCount == 0) return;

        boolean shouldStick = false;
        if (view.getChildAt(0) != mHeader) shouldStick = true;
        else {
            int top = mHeader.getTop();
            int tabsHeight = mHorizontalScrollView.getMeasuredHeight();
            int headerViewHeight = mHeader.getMeasuredHeight();
            int delta = headerViewHeight - tabsHeight;

            if (delta + top < 0) shouldStick = true;
        }

      //refresh the scrollY and the visibility
        if (shouldStick && !mIsSticked) {
            mIsSticked = true;
            mStickyTabs.setScrollY(mTabsInHeader.getScrollY());
            mStickyTabs.setVisibility(View.VISIBLE);
        } else if (!shouldStick && mIsSticked) {
            mIsSticked = false;
            mTabsInHeader.setScrollY(mStickyTabs.getScrollY());
            mStickyTabs.setVisibility(View.INVISIBLE);
        }

    }
});

Пожалуйста, обратите внимание, что это решение не является хорошим решением, потому что у вас есть дубликаты просмотров, и вам придется установить дважды ваши ClickListeners.

Вот перерисовка липких вкладок:

Мы можем заметить, что вид рисуется дважды, поэтому я уверен, что они используют решение 3 или 4.

ЗАКЛЮЧЕНИЕ

Я показал вам 4 способа сделать этот экран:

  1. Вкладки находятся в xml основного представления, и мы используем метод setTranslationY, чтобы заставить его двигаться (игра с полями на устройствах до ICS)
  2. Вкладки находятся в заголовке списка, и мы меняем родителя контейнера вкладок, когда нам нужно его прикрепить (но это дважды вызывает requestLayout)
  3. Вкладки находятся в заголовке списка, и мы просто перерисовываем их по списку, когда нам нужно их прикрепить (но сенсорные события довольно сложно реализовать)
  4. Вкладки находятся как в главном представлении, так и в заголовке списка, и мы играем с setVisibility (INVISIBLE | VISIBLE), но у нас есть дублированное представление, и нам нужно установить clickListeners дважды для каждой вкладки.

Я был не в состоянии найти «хороший» способ реализовать липкие вкладки, но если у вас есть идеи, пожалуйста, не стесняйтесь комментировать или связаться со мной! А если нет, то поделитесь этим!

РЕДАКТИРОВАТЬ

Спасибо, Вольфрам Ритмейер, за предложение использовать uiautomatorviewer и посмотреть, как они на самом деле делают это в приложении Google Plus. Я обнаружил, что они используют решение № 4. Indead, вкладки находятся в заголовке и в FrameLayoutсодержании ListView. Они просто переключают видимость HorizontalScrollViewна ВИДИМО / НЕВИДИМ.