Android Expandable News Feed Example

Tutorial using PlaceHolderView

This view is very different from the Android ExpandableListView in the sense that it’s based on PlaceHolderView which is based on RecyclerView. So, we have the power of reusing the views and managing the memory, that too is a very modular and simple interface.

Objectives Outline for this tutorial:

  1. We would be building a news feed with expandable parent view. On expansion of the one view group collapses the other views.
  2. We will be loading the images from urls and setting it in the view display. For this purpose we will be using a library Glide.
  3. The feed data list will be seeded in the application and this seed json file will be stored in the assets folder.
  4. The seed file will be parsed into Feed object using another library gson.
  5. This structure will also be compatible if we are pulling url json data from a live server.

ExpandablePlaceHolderView:

This view is build upon the PlaceHolderView with the parent child architecture. The detail about this class can be found here

The best approach to understand and appreciate its usage over traditional android ExpandableListView would be to go through the process described below.

Let’s start building:

Step 1:

Set up the project in android studio with default activity.

In app’s build.gradle add the dependencies.

android {
    ...
    sourceSets {
        main {
            assets.srcDirs = ['src/main/assets', 'src/main/assets/']
            res.srcDirs = ['src/main/res', 'src/main/res/drawable']
        }
    }
}

dependencies {
    ...
    compile 'com.mindorks:placeholderview:0.7.1'
    compile 'com.android.support:cardview-v7:25.3.1'
    compile 'com.github.bumptech.glide:glide:3.7.0'
    compile 'com.google.code.gson:gson:2.7'
}

Notes:

  1. Add an assets folder in the src/main directory and point to it in gradle assets.srcDirs
  2. CardView is used to display the image in the list

Add Internet permission in the app’s AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET"/>

Step 2:

Setup project color and drawables:

  1. Download the icon for the expand and collapse indicator from https://design.google.com/icons/ and place them in the drawable folder.
  2. Create two drawable xml files to mark a line between item views

a. res/values/colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#f2b632</color>
    <color name="colorPrimaryDark">#22264b</color>
    <color name="colorAccent">#FF4081</color>
    <color name="bg_color">#e8edf3</color>
    <color name="white">#FFFFFF</color>
</resources>

b. res/drawable/bottom_border_on_dark.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="1dp" />
        </shape>
    </item>
    <item
        android:left="-2dp"
        android:right="-2dp"
        android:top="-2dp"
        android:bottom="1dp">
        <shape android:shape="rectangle">
            <solid android:color="@color/colorPrimaryDark"/>
            <corners android:radius="2dp" />
        </shape>
    </item>
</layer-list>

c. res/drawable/bottom_border_on_light.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="1dp" />
        </shape>
    </item>
    <item
        android:left="-2dp"
        android:right="-2dp"
        android:top="-2dp"
        android:bottom="1dp">
        <shape android:shape="rectangle">
            <solid android:color="@color/colorPrimary"/>
            <corners android:radius="2dp" />
        </shape>
    </item>
</layer-list>

Step 3:

Create src/layout/activity_main.xml

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:background="@color/bg_color"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <com.mindorks.placeholderview.ExpandablePlaceHolderView
        android:id="@+id/expandableView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"/>
</LinearLayout>

Step 4:

Create src/layout/feed_heading.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/toggleView"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingLeft="20dp"
    android:paddingRight="20dp"
    android:paddingTop="10dp"
    android:paddingBottom="10dp"
    android:gravity="center"
    android:background="@drawable/bottom_border_on_dark">
    <TextView
        android:id="@+id/headingTxt"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:singleLine="true"
        android:textColor="@color/white"
        android:textSize="16dp"
        android:typeface="sans"
        android:textStyle="bold"/>
    <View
        android:layout_width="0dp"
        android:layout_height="1dp"
        android:layout_weight="1"/>
    <LinearLayout
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:gravity="center"
        android:orientation="vertical">
        <ImageView
            android:id="@+id/toggleIcon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_keyboard_arrow_down_white_24dp"/>
    </LinearLayout>
</LinearLayout>

Step 5:

Create src/layout/feed_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingLeft="10dp"
    android:paddingRight="20dp"
    android:paddingTop="10dp"
    android:paddingBottom="20dp"
    android:gravity="left"
    android:background="@drawable/bottom_border_on_light">
    <android.support.v7.widget.CardView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        app:cardCornerRadius="4dp"
        app:cardElevation="1dp">
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="60dp"
        android:layout_height="80dp"
        android:layout_gravity="top"
        android:scaleType="centerCrop"/>
    </android.support.v7.widget.CardView>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:orientation="vertical">
        <TextView
            android:id="@+id/titleTxt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@color/white"
            android:textSize="16sp"
            android:typeface="sans"
            android:textStyle="normal"/>
        <TextView
            android:id="@+id/captionTxt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:textColor="@color/white"
            android:textSize="12sp"
            android:typeface="sans"
            android:textStyle="normal"/>
        <TextView
            android:id="@+id/timeTxt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:layout_gravity="right"
            android:textColor="@color/white"
            android:textSize="12sp"
            android:typeface="sans"
            android:textStyle="normal"/>
    </LinearLayout>
</LinearLayout>

Step 6:

Place news.json file in the assets folder created in the above step 1.

[
	{
		"category" : "Top News",
		"data" : [
			{
				"title" : "Nashville Season 5 Premiere Set at CMT",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMjA2NTE0NzkyMF5BMl5BanBnXkFtZTgwMjAwMzg5NjE@._V1._SY140_.jpg",
				"caption" : "The cancelled ABC country-music drama will make its CMT debut with a two-hour premiere on Thursday, January 5, at 9/8c, the cable network announced Wednesday",
				"time" : "1 hours ago"
			},
			{
				"title" : "Sarah Paulson Joins Ryan Murphy's FX Drama Feud as Geraldine Page",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTUzMTA3NjM4MV5BMl5BanBnXkFtZTcwNjk1NTAyMg@@._V1._SY140_.jpg",
				"caption" : "Ryan Murphy is taking a page from his successful playbook, casting frequent collaborator Sarah Paulson in his new FX anthology drama Feud.",
				"time" : "2 hours ago"
			},
			{
				"title" : "Ronald Reagan Biopic Draws ‘Soul Surfer’ Director Sean McNamara",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMzEzOTk4OTQ2OF5BMl5BanBnXkFtZTYwMzkyODQ2._V1._SY140_.jpg",
				"caption" : "\"Soul Surfer\" Sean McNamara has signed to helm a Ronald Reagan biopic that’s set to start production next spring.",
				"time" : "3 hours ago"
			},
			{
				"title" : "Martin Lawrence Gets First Stand-Up Special in 14 Years at Showtime",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTczOTMwOTc1OF5BMl5BanBnXkFtZTgwNTc3MjY5NzE@._V1._SY140_.jpg",
				"caption" : "Martin Lawrence‘s first stand-up special in 14 years will air on Showtime next month, the network announced on Tuesday.",
				"time" : "10 hours ago"
			},
			{
				"title" : "Cmt Announces ‘Nashville’ Season 5 Premiere Date",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMjgwMzg2ODgtZTkzYi00YTU0LThhYjMtMWM0Zjc0ZGFhMDViXkEyXkFqcGdeQXVyNjQxMDY5MjM@._V1._SY140_.jpg",
				"caption" : "\"Nashville\" is heading back to TV with a new network, new showrunners and a new year",
				"time" : "13 hours ago"
			}
		]
	},
	{
		"category" : "Movie News",
		"data" : [
			{
				"title" : "AFI Cancels Birth of a Nation Screening, Nate Parker Q&A",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMzk3MjE5NjE4NF5BMl5BanBnXkFtZTcwOTUzODY0OQ@@._V1._SY140_.jpg",
				"caption" : "The American Film Institute has canceled its Friday screening of “Birth of a Nation,” which was to be followed by a Q&A with filmmaker and star Nate Parker, whose 1999 rape case has put the filmmaker and distributor Fox Searchlight on the defensive this past week.",
				"time" : "2 hours ago"
			},
			{
				"title" : "Horror Movie ‘Don’t Breathe’ to Scare Off ‘Suicide Squad’ for No 1 Spot",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BZGI5ZTU2M2YtZWY4MC00ZDFhLTliYTItZTk1NjdlN2NkMzg2XkEyXkFqcGdeQXVyMjY5ODI4NDk@._V1._SY140_.jpg",
				"caption" : "After three straight weekends at No. 1, Warner Bros. comic book hit “Suicide Squad” is finally going down.",
				"time" : "4 hours ago"
			},
			{
				"title" : "After The Infiltrator Fizzles, Broad Green Plots Its Comeback",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTEwNzM2NjY2MTNeQTJeQWpwZ15BbWU4MDQ3MDI3Njgx._V1._SY140_.jpg",
				"caption" : "Gabriel Hammond was frustrated.",
				"time" : "3 hours ago"
			},
			{
				"title" : "The Fall: Series 3 Teaser Trailer: Gillian Anderson is Haunted By Killer Jamie Dornan",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTQzMTk4MjY3NF5BMl5BanBnXkFtZTcwMzI1ODAxOA@@._V1._SY140_.jpg",
				"caption" : "It’s been two years since audiences last saw Gillian Anderson as the driven and obsessive Stella Gibson, the detective superintendent who’s been after serial killer Paul Spector (played by Jamie Dornan) in BBC Two’s drama \"The Fall.\"",
				"time" : "6 hours ago"
			}
		]
	},
	{
		"category" : "TV News",
		"data" : [
			{
				"title" : "Nashville Season 5 Premiere Set at CMT",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMjA2NTE0NzkyMF5BMl5BanBnXkFtZTgwMjAwMzg5NjE@._V1._SY140_.jpg",
				"caption" : "Nashville‘s comeback tour will kick off just after the new year.",
				"time" : "40 minutes ago"
			},
			{
				"title" : "Lifetime Developing Robert Durst TV Movie",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BNTMyMzc4NTk2NV5BMl5BanBnXkFtZTgwNjMyMzYyNDE@._V1._SY140_.jpg",
				"caption" : "The Robert Durst story is headed to TV — again.",
				"time" : "2 hours ago"
			},
			{
				"title" : "Ming-Na Wen goes from S.H.I.E.L.D. agent to time travel agent for Disney XD",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTIyNjUwNTk3OV5BMl5BanBnXkFtZTcwOTE4NTMzMQ@@._V1._SY140_.jpg",
				"caption" : "AnimationFix: Your regular round-up of the latest animation news, from HitFix reporter Emily Rome Ming-Na Wen is ever-strengthening her family ties to Disney",
				"time" : "23 hours ago"
			}
		]
	},
	{
		"category" : "Celebrity News",
		"data" : [
			{
				"title" : "Scott Eastwood’s Deceased Girlfriend Identified as Aspiring Model Jewel Brangman",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTczODYzOTA0Nl5BMl5BanBnXkFtZTgwNDcwMzIyNTE@._V1._SY140_.jpg",
				"caption" : "Scott Eastwood did not reveal the name of his former girlfriend in his emotional revelation about her death this week, but she has since been identified as aspiring model Jewel Brangman.",
				"time" : "10 hours ago"
			},
			{
				"title" : "Mady and Cara Gosselin Open Up About Their Estranged Relationship with Dad Jon: 'He Doesn't Even Know Us'",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMTQ1NTU3MjE1OF5BMl5BanBnXkFtZTcwODEzMjYyMw@@._V1._SY140_.jpg",
				"caption" : "Kate Gosselin and her kids open up about how finding reality TV fame more than 10 years ago changed their lives forever. Subscribe now for an inside look at how they've overcome setbacks and remain hopeful for the future",
				"time" : "12 hours ago"
			},
			{
				"title" : "Dream Getaways: Celebrity Couples' Luxurious Vacation Homes",
				"image_url" : "http://ia.media-imdb.com/images/M/MV5BMjEyMTEyOTQ0MV5BMl5BanBnXkFtZTcwNzU3NTMzNw@@._V1._SY140_.jpg",
				"caption" : "Who needs one house when you can have two?!  Celebrity couples already live a life of luxury in their everyday mansions, but some husband and wives opt to purchase another home for when they feel like getting away on vacation.",
				"time" : "23 hours ago"
			}
		]
	}
]

Notes:

  1. This strategy is very useful in bundling app with seed files. Seed files contain data build in the app package and can be used to populate database or use to display default data to the user. Placing seed files in the form of json makes is extremely easy to parse into models.

Step 7:

Create Utils.java

public class Utils {

    private static final String TAG = "Utils";

    public static List<Feed> loadFeeds(Context context){
        try{
            GsonBuilder builder = new GsonBuilder();
            Gson gson = builder.create();
            JSONArray array = new JSONArray(loadJSONFromAsset(context, "news.json"));
            List<Feed> feedList = new ArrayList<>();
            for(int i=0;i<array.length();i++){
                Feed feed = gson.fromJson(array.getString(i), Feed.class);
                feedList.add(feed);
            }
            return feedList;
        }catch (Exception e){
            Log.d(TAG,"seedGames parseException " + e);
            e.printStackTrace();
            return null;
        }
    }

    private static String loadJSONFromAsset(Context context, String jsonFileName) {
        String json = null;
        InputStream is = null;
        try {
            AssetManager manager = context.getAssets();
            Log.d(TAG,"path "+jsonFileName);
            is = manager.open(jsonFileName);
            int size = is.available();
            byte[] buffer = new byte[size];
            is.read(buffer);
            is.close();
            json = new String(buffer, "UTF-8");
        } catch (IOException ex) {
            ex.printStackTrace();
            return null;
        }
        return json;
    }
}

Note:

  1. Utils contain methods required to parse seed json file and also populate the model Feed.java and Info.java

Step 8:

Create model Feed.java

public class Feed {

    @SerializedName("category")
    @Expose
    private String heading;

    @SerializedName("data")
    @Expose
    private List<Info> infoList;

    public String getHeading() {
        return heading;
    }

    public void setHeading(String heading) {
        this.heading = heading;
    }

    public List<Info> getInfoList() {
        return infoList;
    }

    public void setInfoList(List<Info> infoList) {
        this.infoList = infoList;
    }
}

Notes:

  1. @SerializedName annotation belongs to gson class and used to read json file variable and bind it to the model variable.
  2. @Expose is used to make the variable readable to the gson

Step 9:

Create model Info.java

public class Info {

    @SerializedName("title")
    @Expose
    private String title;

    @SerializedName("image_url")
    @Expose
    private String imageUrl;

    @SerializedName("caption")
    @Expose
    private String caption;

    @SerializedName("time")
    @Expose
    private String time;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getImageUrl() {
        return imageUrl;
    }

    public void setImageUrl(String imageUrl) {
        this.imageUrl = imageUrl;
    }

    public String getCaption() {
        return caption;
    }

    public void setCaption(String caption) {
        this.caption = caption;
    }

    public String getTime() {
        return time;
    }

    public void setTime(String time) {
        this.time = time;
    }
}

Step 10:

We will now create the class to bind the item views and its operations.

Create HeadingView.java for the parent view

@Parent
@SingleTop
@Layout(R.layout.feed_heading)
public class HeadingView {

    @View(R.id.headingTxt)
    private TextView headingTxt;

    @View(R.id.toggleIcon)
    private ImageView toggleIcon;

    @Toggle(R.id.toggleView)
    private LinearLayout toggleView;

    @ParentPosition
    private int mParentPosition;

    private Context mContext;
    private String mHeading;

    public HeadingView(Context context, String heading) {
        mContext = context;
        mHeading = heading;
    }

    @Resolve
    private void onResolved() {
        toggleIcon.setImageDrawable(mContext.getResources().getDrawable(R.drawable.ic_keyboard_arrow_up_white_24dp));
        headingTxt.setText(mHeading);
    }

    @Expand
    private void onExpand(){
        toggleIcon.setImageDrawable(mContext.getResources().getDrawable(R.drawable.ic_keyboard_arrow_down_white_24dp));
    }

    @Collapse
    private void onCollapse(){
        toggleIcon.setImageDrawable(mContext.getResources().getDrawable(R.drawable.ic_keyboard_arrow_up_white_24dp));
    }
}

Notes:

  1. @Parent is annotation used to bind a class as the parent view.
  2. @layout is used to bind the layout with this class
  3. @SingleTop is used to keep only one parent in expanded state and others in collapsed state.
  4. @View is used to bind the views in a layout we want to refer to
  5. @Toggle is used to provide a view in the layout to be used as a toggle for expanding or collapsing a parent on view click. If not provided then the parent view is used as a toggle view by default.
  6. @ParentPosition is used bind an int variable to be updated with the relative position of a parent with respect to other parents.
  7. @Expand is used to get a method invoked when the parent view expands.
  8. @Collapse is used to get a method invoked when the parent view collapses.

For detailed explanations view PlaceHolderView at GitHub repository

Step 11:

Create InfoView.java for the child views

@Layout(R.layout.feed_item)
public class InfoView {

    @ParentPosition
    private int mParentPosition;

    @ChildPosition
    private int mChildPosition;

    @View(R.id.titleTxt)
    private TextView titleTxt;

    @View(R.id.captionTxt)
    private TextView captionTxt;

    @View(R.id.timeTxt)
    private TextView timeTxt;

    @View(R.id.imageView)
    private ImageView imageView;

    private Info mInfo;
    private Context mContext;

    public InfoView(Context context, Info info) {
        mContext = context;
        mInfo = info;
    }

    @Resolve
    private void onResolved() {
        titleTxt.setText(mInfo.getTitle());
        captionTxt.setText(mInfo.getCaption());
        timeTxt.setText(mInfo.getTime());
        Glide.with(mContext).load(mInfo.getImageUrl()).into(imageView);
    }
}

Notes:

  1. Most annotations used are described in step 10.
  2. @ChildPosition is used to bind an int variable to be updated with the relative position among children of a parent.

Step 12:

Create MainActivity.java

public class MainActivity extends AppCompatActivity {

    private ExpandablePlaceHolderView mExpandableView;
    private Context mContext;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = this.getApplicationContext();
        mExpandableView = (ExpandablePlaceHolderView)findViewById(R.id.expandableView);
        for(Feed feed : Utils.loadFeeds(this.getApplicationContext())){
            mExpandableView.addView(new HeadingView(mContext, feed.getHeading()));
            for(Info info : feed.getInfoList()){
                mExpandableView.addView(new InfoView(mContext, info));
            }
        }
    }
}

Notes:

  1. We obtain the instance of the ExpandablePlaceHolderView and add views using the feed data from the model list.
  2. Parent and child should be added in sequence and parent must be added before its child

PlaceHolderView GitHub repository is here

The source code for this example is here

Coders Rock!!