实现 Instagram 的 Material Design 概念设计

发布于 2024-09-04 07:42:11 字数 22991 浏览 17 评论 0

几个月前(这篇文章的日期是 2014 年 11 月 10 日),google 发布了 app 和 web 应用的 Material Design 设计准则之后,设计师 Emmanuel Pacamalan 在 youtube 上发布了一则概念视频,演示了 Instagram 如果做成 Material 风格会是什么样子:

视频地址在这里 http://v.youku.com/v_show/id_XODg2NDQ1NDQ4.html ps:markdown 不支持播放优酷视频

这 仅仅是停留在图像上的设计,是美好的愿景,估计很多人都会问,能否使用相对简单的办法将它实现出来呢?答案是:yes,不仅仅能实现,而且无须要求在 Lillipop 版本,实际上几年前 4.0 发布之后我们就可以实现这些效果了。ps 读到这里我们应该反思这几年开发者是不是都吃屎去了。

鉴于这个原因,我决定开始撰写一个新的课题-如何将 INSTAGRAM with Material Design 视频中的效果转变成现实。当然,我们并不是真的要做一个 Instagram 应用,只是将界面做出来而已,并且尽量减少一些不必要的细节。

开始

本文将要实现的是视频中前 7 秒钟的效果。我觉得对于第一次尝试来说已经足够了。我想要提醒诸位的是,里面的实现方法不仅仅是能实现,也是我个人最喜欢的实现方式。还有,我不是一个美工,因此项目中的所有图片是直接从网上公开的渠道获取的。(主要是从 resources page )。

好了,下面是最终效果的两组截图和视频(很短的视频,就是那 7 秒钟的效果,可以在上面的视频中看到,这里因为没法直接引用 youtube 的视频就略了)(分别从 Android 4 和 5 上获得的)。

准备

在我们的项目中,将使用一些热门的 android 开发工具和库。并不是所有这些东西本篇文章都会用到,我只是将它们准备好以备不时之需。 初始化项目

首先我们需要创建一个新的 android 项目。我使用的是 Android Studio 和 gradle 的 build 方式。最低版本的 sdk 是 15(即 Android 4.0.4)。然后我们将添加一些依赖。没什么好讲的,下面是 build.gradle 以及 app/build.gradle 文件的代码:

build.gradle

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:0.14.0'
        classpath 'com.jakewharton.hugo:hugo-plugin:1.1.+'
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

app/build.gradle

apply plugin: 'com.android.application'
apply plugin: 'hugo'

android {
    compileSdkVersion 21
    buildToolsVersion "21.1"

    defaultConfig {
        applicationId "io.github.froger.instamaterial"
        minSdkVersion 15
        targetSdkVersion 21
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])

    compile "com.android.support:appcompat-v7:21.0.0"
    compile 'com.android.support:support-v13:21.+'
    compile 'com.android.support:support-v4:21.+'
    compile 'com.android.support:palette-v7:+'
    compile 'com.android.support:recyclerview-v7:+'
    compile 'com.android.support:cardview-v7:21.0.+'
    compile 'com.jakewharton:butterknife:5.1.2'
    compile 'com.jakewharton.timber:timber:2.5.0'
    compile 'com.facebook.rebound:rebound:0.3.6'
}

简而言之,我们有如下工具:

一些兼容包(CardView, RecyclerView, Palette, AppCompat)-我喜欢使用最新的控件。当然你完全可以使用 ListView Actionbar 甚至 View/FrameView 来替代,但是为什么要这么折腾?

  • ButterKnife - view 注入工具简化我们的代码。(比方说不再需要写 findViewById() 来引用 view,以及一些更强大的功能)。
  • Rebound - 我们目前还没有用到,但是我以后肯定会用它。这个 facebook 开发的动画库可以让你的动画效果看起来更自然。
  • TimberHugo - 对这个项目而言并不是必须,我仅仅是用他们打印 log。

图片资源

本项目中将使用到一些 Material Design 的图标资源。App 图标来自于 NSTAGRAM with Material Design 视频, 这里 是项目的全套资源。

样式

我们从定义 app 的默认样式开始。同时为 Android 4 和 5 定义 Material Desing 样式的最简单的方式是直接继承 Theme.AppCompat.NoActionBar 或者 Theme.AppCompat.Light.NoActionBar 主题。为什么是 NoActionBar?因为新的 sdk 中为我们提供了实现 Actionbar 功能的新模式。本例中我们将使用 Toolbar 控件,基于这个原因-Toolbar 是 ActionBar 更好更灵活的解决方案。我们不会深入讲解这个问题,但你可以去阅读 android 开发者博客 AppCompat v21

根据概念视频中的效果,我们在 AppTheme 中定义了三个基本颜色(基色调):

styles.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- styles.xml-->
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary">@color/style_color_primary</item>
        <item name="colorPrimaryDark">@color/style_color_primary_dark</item>
        <item name="colorAccent">@color/style_color_accent</item>
    </style>
</resources>

colors1.xml

<?xml version="1.0" encoding="utf-8"?>
<!--colors.xml-->
<resources>
    <color name="style_color_primary">#2d5d82</color>
    <color name="style_color_primary_dark">#21425d</color>
    <color name="style_color_accent">#01bcd5</color>
</resources>

关于这三个颜色的意义,你可以在这里找到 Material Theme Color Palette documentation

布局

项目目前主要使用了 3 个主要的布局元素

*Toolbar* - 包含导航图标和 applogo 的顶部 bar

*TRecyclerView*T - 用于显示 feed

*TFloating Action Button* - 一个实现了 Material Design 中[action button pattern](http://www.google.com/design/spec/components/buttons.html#buttons-flat-raised-buttons) 的 ImageButton。

在开始实现布局之前,我们先在 res/values/dimens.xml 文件中定义一些默认值:

<?xml version="1.0" encoding="utf-8"?>
<!--dimens.xml-->
<resources>
    <dimen name="btn_fab_size">56dp</dimen>
    <dimen name="btn_fab_margins">16dp</dimen>
    <dimen name="default_elevation">8dp</dimen>
</resources>

这些值的大小是基于 Material Design 设计准则中的介绍。

现在我们来实现 MainActivity 中的 layout:

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary">

        <ImageView
            android:id="@+id/ivLogo"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center"
            android:scaleType="center"
            android:src="@drawable/img_toolbar_logo" />
    </android.support.v7.widget.Toolbar>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvFeed"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar"
        android:scrollbars="none" />

    <ImageButton
        android:id="@+id/btnCreate"
        android:layout_width="@dimen/btn_fab_size"
        android:layout_height="@dimen/btn_fab_size"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"
        android:layout_marginBottom="@dimen/btn_fab_margins"
        android:layout_marginRight="@dimen/btn_fab_margins"
        android:background="@drawable/btn_fab_default"
        android:elevation="@dimen/default_elevation"
        android:src="@drawable/ic_instagram_white"
        android:textSize="28sp" />

</RelativeLayout>

以上代码的解释:

关于 Toolbar 最重要的特征是他现在是 activity layout 的一部分,而且继承自 ViewGroup,因此我们可以在里面放一些 UI 元素(它们将利用剩余空间)。本例中,它被用来放置 logo 图片。同时,因为 Toolbar 是比 Actionbar 更灵活的控件,我们可以自定义更多的东西,比如设置背景颜色为 colorPrimary(否则 Toolbar 将是透明的)。

RecyclerView 虽然在 xml 中用起来非常简单,但是如果 java 代码中没有设置正确,app 是不能启动的,会报 java.lang.NullPointerException。


Elevation(ImageButton 中)属性不兼容 api21 以前的版本。所以如果我们想做到 Floating Action Button 的效果需要在 Lollipop 和 Lollipop 之前的设备上使用不同的 background。

Floating Action Button

为了简化 FAB 的使用,我们将用对 Lollipop 以及 Lollipop 之前的设备使用不同的样式。

我们需要创建两个不同的 xml 文件来设置 button 的 background:/res/drawable-v21/btn_fab_default.xml(Lollipop 设备) ,/res/drawable/btn_fab_default.xml(Lollipop 之前的设备):

btn_fab_default2.xml

<?xml version="1.0" encoding="utf-8"?>
<!--drawable-v21/btn_fab_default.xml-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/fab_color_shadow">
    <item>
    <shape android:shape="oval">
    <solid android:color="@color/style_color_accent" />
    </shape>
    </item>
</ripple>

btn_fab_default1.xml

<?xml version="1.0" encoding="utf-8"?>
<!--drawable/btn_fab_default.xml-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="false">
        <layer-list>
            <item android:bottom="0dp" android:left="2dp" android:right="2dp" android:top="2dp">
                <shape android:shape="oval">
                    <solid android:color="@color/fab_color_shadow" />
                </shape>
            </item>

            <item android:bottom="2dp" android:left="2dp" android:right="2dp" android:top="2dp">
                <shape android:shape="oval">
                    <solid android:color="@color/style_color_accent" />
                </shape>
            </item>
        </layer-list>
    </item>
    <item android:state_pressed="true">
        <shape android:bottom="2dp" android:left="2dp" android:right="2dp" android:shape="oval" android:top="2dp">
        <solid android:color="@color/fab_color_pressed" />
        </shape>
    </item>
</selector>

上面的代码涉及到两个颜色的定义,在 res/values/colors.xml 中添加:

<color name="btn_default_light_normal">#00000000</color>
<color name="btn_default_light_pressed">#40ffffff</color>

可以看到在 21 之前的设备商制造阴影比较复杂。不幸的是在 xml 中达到真实的阴影效果没有渐变方法。其他的办法是使用图片的方式,或者通过 java 代码实现(参见 creating fab shadow )。

Toolbar

现在我们来完成 Toolbar。我们已经有了 background 和应用的 logo,现在还剩下 navigation 以及 menu 菜单图标了。关于 navigation,非常不幸的是,在 xml 中 app:navigationIcon=""是不起作用的,而 android:navigationIcon=""又只能在 Lollipop 上有用,所以只能使用代码的方式了:

toolbar.setNavigationIcon(R.drawable.ic_menu_white);

注:app:navigationIcon=""的意思是使用兼容包 appcompat 的属性,而 android:navigationIcon=""是标准的 sdk 属性。

至于 menu 图标我们使用标准的定义方式就好了:

在 res/menu/menu_main.xml 中

<!--menu_main.xml-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">
        <item
            android:id="@+id/action_inbox"
            android:icon="@drawable/ic_inbox_white"
            android:title="Inbox"
            app:showAsAction="always" />
</menu>

在 activity 中 inflated 这个 menu:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    return true;
}

本应运行的很好,但是正如我在 twitter 上提到的,Toolbar onClick selectors 有不协调的情况:

为了解决这个问题,需要做更多的工作,首先为 menu item 创建一个自定义的 view

res/layout/menu_item_view.xml:

<?xml version="1.0" encoding="utf-8"?>
<!--menu_item_view.xml-->
<ImageButton xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="?attr/actionBarSize"
    android:layout_height="?attr/actionBarSize"
    android:background="@drawable/btn_default_light"
    android:src="@drawable/ic_inbox_white" />

然后为 Lollipop 和 Lollipop 之前的设备分别创建 onClick 的 selector,在 Lollipop 上有 ripple 效果: btn_default_light2.xml

<?xml version="1.0" encoding="utf-8"?>
<!--drawable-v21/btn_default_light.xml-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/btn_default_light_pressed" />

btn_default_light1.xml

<?xml version="1.0" encoding="utf-8"?>
<!--drawable/btn_default_light.xml-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/btn_default_light_normal" android:state_focused="false" android:state_pressed="false" />
    <item android:drawable="@color/btn_default_light_pressed" android:state_pressed="true" />
    <item android:drawable="@color/btn_default_light_pressed" android:state_focused="true" />
</selector>

现在,工程中的所有的 color 应该是这样子了:

colors.xml

<?xml version="1.0" encoding="utf-8"?>
<!--colors.xml-->
<resources>
<color name="style_color_primary">#2d5d82</color>
<color name="style_color_primary_dark">#21425d</color>
<color name="style_color_accent">#01bcd5</color>

<color name="fab_color_pressed">#007787</color>
<color name="fab_color_shadow">#44000000</color>

<color name="btn_default_light_normal">#00000000</color>
<color name="btn_default_light_pressed">#40ffffff</color>
</resources>

最后我们应该将 custom view 放到 menu item 中,在 onCreateOptionsMenu() 中:

 @Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    inboxMenuItem = menu.findItem(R.id.action_inbox);
    inboxMenuItem.setActionView(R.layout.menu_item_view);
    return true;
}

以上就是 toolbar 的所有东西。并且 onClick 的按下效果也达到了预期的效果。

Feed

最后需要实现的是 feed,基于 RecyclerView 实现。我们需要设置两个东西:layout manager 和 adapter,因为这里其实就是想实现 ListView 的效果,所以直接用 LinearLayoutManager 就行了,而 adapter 我们首先从 item 的布局开始(res/layout/item_feed.xml):

item_feed.xml

<?xml version="1.0" encoding="utf-8"?><!-- item_feed.xml -->
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:id="@+id/card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:layout_margin="8dp"
    card_view:cardCornerRadius="4dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_feed_top" />

        <io.github.froger.instamaterial.SquaredImageView
            android:id="@+id/ivFeedCenter"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <ImageView
            android:id="@+id/ivFeedBottom"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>
</android.support.v7.widget.CardView>

FeedAdapter 也非常简单:

FeedAdapter.java

public class FeedAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    private static final int ANIMATED_ITEMS_COUNT = 2;

    private Context context;
    private int lastAnimatedPosition = -1;
    private int itemsCount = 0;

    public FeedAdapter(Context context) {
        this.context = context;
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        final View view = LayoutInflater.from(context).inflate(R.layout.item_feed, parent, false);
        return new CellFeedViewHolder(view);
    }

    private void runEnterAnimation(View view, int position) {
        if (position >= ANIMATED_ITEMS_COUNT - 1) {
            return;
        }

        if (position > lastAnimatedPosition) {
            lastAnimatedPosition = position;
            view.setTranslationY(Utils.getScreenHeight(context));
            view.animate()
            .translationY(0)
            .setInterpolator(new DecelerateInterpolator(3.f))
            .setDuration(700)
            .start();
        }
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
        runEnterAnimation(viewHolder.itemView, position);
        CellFeedViewHolder holder = (CellFeedViewHolder) viewHolder;
        if (position % 2 == 0) {
            holder.ivFeedCenter.setImageResource(R.drawable.img_feed_center_1);
            holder.ivFeedBottom.setImageResource(R.drawable.img_feed_bottom_1);
        } else {
            holder.ivFeedCenter.setImageResource(R.drawable.img_feed_center_2);
            holder.ivFeedBottom.setImageResource(R.drawable.img_feed_bottom_2);
        }
    }

    @Override
    public int getItemCount() {
        return itemsCount;
    }

    public static class CellFeedViewHolder extends RecyclerView.ViewHolder {
        @InjectView(R.id.ivFeedCenter)
        SquaredImageView ivFeedCenter;
        @InjectView(R.id.ivFeedBottom)
        ImageView ivFeedBottom;

        public CellFeedViewHolder(View view) {
        super(view);
            ButterKnife.inject(this, view);
        }
    }

    public void updateItems() {
        itemsCount = 10;
        notifyDataSetChanged();
    }
}

没什么特别之处需要说明。

通过以下方法将他们放在一起:

private void setupFeed() {
    LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
    rvFeed.setLayoutManager(linearLayoutManager);
    feedAdapter = new FeedAdapter(this);
    rvFeed.setAdapter(feedAdapter);
}

下面是整个 MainActivity class 的源码: //MainActivity.java

public class MainActivity extends ActionBarActivity {
    @InjectView(R.id.toolbar)
    Toolbar toolbar;
    @InjectView(R.id.rvFeed)
    RecyclerView rvFeed;

    private MenuItem inboxMenuItem;
    private FeedAdapter feedAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.inject(this);

        setupToolbar();
        setupFeed();
    }

    private void setupToolbar() {
        setSupportActionBar(toolbar);
        toolbar.setNavigationIcon(R.drawable.ic_menu_white);
    }

    private void setupFeed() {
        LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
        rvFeed.setLayoutManager(linearLayoutManager);
        feedAdapter = new FeedAdapter(this);
        rvFeed.setAdapter(feedAdapter);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.menu_main, menu);
        inboxMenuItem = menu.findItem(R.id.action_inbox);
        inboxMenuItem.setActionView(R.layout.menu_item_view);
        return true;
    }
}

动画

最后一件也是最重要的事情就是进入时的动画效果,再浏览一遍概念视频,可以发现在 main Activity 启动的时候有如下动画,分成两步:

显示 Toolbar 以及其里面的元素

在 Toolbar 动画完成之后显示 feed 和 floating action button。

Toolbar 中元素的动画表现为在较短的时间内一个接一个的进入。实现这个效果的主要问题在于 navigation icon 的动画,navigation icon 是唯一一个不能使用动画的,其他的都好办。 Toolbar animation

首先我们只是需要在 activity 启动的时候才播放动画(在旋转屏幕的时候不播放),还要知道 menu 的动画过程是不能在 onCreate() 中去实现的(我们在 onCreateOptionsMenu() 中实现),创建一个布尔类型的变量 pendingIntroAnimation ,在 onCreate() 方法中初始化:

//...
if (savedInstanceState == null) {
    pendingIntroAnimation = true;
}

onCreateOptionsMenu():

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    inboxMenuItem = menu.findItem(R.id.action_inbox);
    inboxMenuItem.setActionView(R.layout.menu_item_view);
    if (pendingIntroAnimation) {
        pendingIntroAnimation = false;
        startIntroAnimation();
    }
    return true;
}

这样 startIntroAnimation() 将只被调用一次。

现在该来准备 Toolbar 中元素的动画了,也非常简单

ToolbarAnimation

//...
private static final int ANIM_DURATION_TOOLBAR = 300;

private void startIntroAnimation() {
    btnCreate.setTranslationY(2 * getResources().getDimensionPixelOffset(R.dimen.btn_fab_size));
    int actionbarSize = Utils.dpToPx(56);
    toolbar.setTranslationY(-actionbarSize);
    ivLogo.setTranslationY(-actionbarSize);
    inboxMenuItem.getActionView().setTranslationY(-actionbarSize);

    toolbar.animate()
        .translationY(0)
        .setDuration(ANIM_DURATION_TOOLBAR)
        .setStartDelay(300);
    ivLogo.animate()
        .translationY(0)
        .setDuration(ANIM_DURATION_TOOLBAR)
        .setStartDelay(400);
    inboxMenuItem.getActionView().animate()
        .translationY(0)
        .setDuration(ANIM_DURATION_TOOLBAR)
        .setStartDelay(500)
        .setListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
            startContentAnimation();
            }
        })
        .start();
}
//...

在上面的代码中:

首先我们将所有的元素都通过移动到屏幕之外隐藏起来(这一步我们将 FAB 也隐藏了)。

让 Toolbar 元素一个接一个的开始动画

当动画完成,调用了 startContentAnimation() 开始 content 的动画(FAB 和 feed 卡片的动画)

简单,是吧? Content 动画

在这一步中我们将让 FAB 和 feed 卡片动起来。FAB 的动画很简单,跟上面的方法类似,但是 feed 卡片稍微复杂些。

startContentAnimation 方法

//...
//FAB animation
private static final int ANIM_DURATION_FAB = 400;

private void startContentAnimation() {
    btnCreate.animate()
        .translationY(0)
        .setInterpolator(new OvershootInterpolator(1.f))
        .setStartDelay(300)
        .setDuration(ANIM_DURATION_FAB)
        .start();
    feedAdapter.updateItems();
}
//...

FeedAdapter 的代码在上面已经贴出来了。结合着就知道动画是如何实现的了。

本篇文章就结束了,避免遗漏,这里是这篇文章是提交的代码 commit for our project with implemented animations .

源代码

完整的代码在 Github repository .

作者: Miroslaw Stanek

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

殤城〤

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

玍銹的英雄夢

文章 0 评论 0

我不会写诗

文章 0 评论 0

十六岁半

文章 0 评论 0

浸婚纱

文章 0 评论 0

qq_kJ6XkX

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文