Ten years on, and this is still amazingly difficult!
Jetpack Compose now has a DataTable control, but this is not yet available for Android: Jetpack Compose: Data Tables.
I have been working on a Kotlin App for my own learning, and wanted to display a data table. I have taken the ideas from this thread, together with the linked scrolling from: Synchronise ScrollView scroll positions - android.
The full code is available on GitHub: CovidStatistics.
The table is shown in the screenshot below. You can see that the Data Table has grid lines, and scrolls in both directions. Also, it display icons on some of the cells.
My solution adds the following to those presented above in this thread:
- The data cell is defined in a layout file, not in code, which makes it easier to display complex layouts in a cell. The icons are shown randomly as an example of this.
- The column widths are automatically set after the data is added to the table, rather than having to be chosen beforehand.
I had hoped to use RecyclerViews, instead of creating rows and columns in code. I got part way towards this but just couldn't get the horizontal scroll bar to work with RecyclerView.

This is the layout code for the table fragment:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:gravity="center_horizontal"
android:id="@+id/main_area"
android:layout_marginTop="0in"
android:layout_marginBottom="0in"
android:layout_marginLeft="0in"
android:layout_marginRight="0in"
android:background="@android:color/holo_green_dark">
<ProgressBar
android:id="@+id/progressBar1"
style="?android:attr/progressBarStyleLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:tooltipText="Loading data"
android:visibility="visible" />
<LinearLayout android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:id="@+id/header_area"
>
<!-- Top left cell, in a table of its own-->
<TableLayout
android:id="@+id/top_left_cell"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
app:layout_constraintStart_toStartOf="parent"
android:paddingLeft="0in"
android:paddingRight="0in"
android:paddingTop="0in"
android:paddingBottom="0in"
/>
<!-- Column header horizontal scroll-->
<com.ant_waters.covidstatistics.ui.ObservableHorizontalScrollView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:id="@+id/column_header_scroll"
>
<!-- Column Headers-->
<TableLayout
android:id="@+id/table_header"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
>
</TableLayout>
</com.ant_waters.covidstatistics.ui.ObservableHorizontalScrollView>
</LinearLayout>
<!-- Data area vertical scroll-->
<ScrollView
android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<LinearLayout android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:gravity="center_horizontal"
android:id="@+id/fillable_area"
>
<!-- Data row headers-->
<TableLayout
android:id="@+id/fixed_column"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
app:layout_constraintStart_toStartOf="@+id/table_header"
android:paddingLeft="0in"
android:paddingRight="0in"
android:paddingTop="0in"
android:paddingBottom="0in"
/>
<!-- Data rows horizontal scroll-->
<com.ant_waters.covidstatistics.ui.ObservableHorizontalScrollView
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:id="@+id/data_horizontal_scroll"
>
<!-- Data rows-->
<TableLayout
android:id="@+id/scrollable_part"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:paddingLeft="0in"
android:paddingRight="0in"
android:paddingTop="0in"
android:paddingBottom="0in"
/>
</com.ant_waters.covidstatistics.ui.ObservableHorizontalScrollView>
</LinearLayout>
</ScrollView>
</LinearLayout>
These are the layout codes for other key pieces:
Header_cell:
<!--This defines the layout for a header cell in the data table-->
<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="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/cell_linear_layout"
android:layout_marginTop="0in"
android:layout_marginBottom="0in"
android:layout_marginLeft="0in"
android:layout_marginRight="0in"
>
<!-- android:gravity="center_horizontal"-->
<!-- android:background="@android:color/holo_green_dark"-->
<TextView
android:id="@+id/cell_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"
android:textColor="@color/black"
android:textSize="20.0sp"
android:layout_gravity="center_horizontal|center_vertical"
android:paddingStart="15sp"
android:paddingEnd="15sp"
android:paddingTop="5sp"
android:paddingBottom="5sp"
android:background="#4b8ba1"
></TextView>
</LinearLayout>
Data cell containing an icon:
<!--This defines the layout for a data cell that includes a warning icon in the data table-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cell_linear_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<!-- This layer is needed to set the margin, as that doesn't seem to work at the top level -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"
android:background="@android:color/white"
android:orientation="horizontal">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_baseline_warning_24" />
<TextView
android:id="@+id/cell_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|center_vertical"
android:paddingStart="15sp"
android:paddingTop="5sp"
android:paddingEnd="15sp"
android:paddingBottom="5sp"
android:textColor="@color/black"
android:textSize="20.0sp"></TextView>
</LinearLayout>
</LinearLayout>
Header cell background:
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/darker_gray" />
</shape>
While the table is built it fills in a 2-d array of Views for all the cells being display.
fun displayTheDataTable(inflater: LayoutInflater)
{
if (MainViewModel.DataInitialised.value==enDataLoaded.All) {
// Display the table by creating Views for cells, headers etc.
val allCells = displayDataTable(inflater)
// Add a callback to set the column widths at the end (when onGlobalLayout is called)
val content: View = _binding!!.mainArea
content.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
//Remove the observer so we don't get this callback for EVERY layout pass
content.viewTreeObserver.removeGlobalOnLayoutListener(this)
//Resize the columns to match the maximum width
setColumnWidths(allCells, fun (v: View, w: Int) {
val tv = v.findViewById<View>(com.ant_waters.covidstatistics.R.id.cell_text_view) as TextView
var lp = LayoutParams(w, LayoutParams.WRAP_CONTENT)
v.layoutParams = lp
tv.setGravity(Gravity.CENTER)
})
}
})
}
}
This is used to set the column widths at the end:
fun setColumnWidths(allCells: Array<Array<View?>>, setItemWidth: (v: View, w: Int) -> Unit)
{
val numColumns: Int = allCells[0].size
val colWidths = Array<Int>(numColumns, {0})
for (r in 0..allCells.size-1)
{
if ((allCells[r] == null) || (allCells[r][0] == null)) { continue } // Row was skipped
for (c in 0..numColumns-1)
{
val vw : View = allCells[r][c]!!
if (vw.width > colWidths[c]) { colWidths[c] = vw.width}
}
}
for (r in 0..allCells.size-1)
{
if ((allCells[r] == null) || (allCells[r][0] == null)) { continue } // Row was skipped
for (c in 0..numColumns-1)
{
val vw = allCells[r][c]!! as LinearLayout
setItemWidth(vw, colWidths[c])
}
}
}
These are the methods to build the individual cells from the "templates" defined in layout files:
fun createHeaderCellFromTemplate(inflater: LayoutInflater, text: String?): View {
val cellView: View = inflater.inflate(com.ant_waters.covidstatistics.R.layout.header_cell, null)
val tv = cellView.findViewById<View>(com.ant_waters.covidstatistics.R.id.cell_text_view) as TextView
tv.text = text
return cellView
}
fun <TRowHdr>createRowHeaderCellFromTemplate(inflater: LayoutInflater, rowHdr: TRowHdr): View {
val cellView: View = inflater.inflate(com.ant_waters.covidstatistics.R.layout.header_cell, null)
val tv = cellView.findViewById<View>(com.ant_waters.covidstatistics.R.id.cell_text_view) as TextView
if (rowHdr is Date)
{
val dt = rowHdr as Date
var formatter = SimpleDateFormat("dd/MM/yy")
tv.text = formatter.format(dt)
}
else {
tv.text = rowHdr.toString()
}
return cellView
}
fun <Tval>createDataCellFromTemplate(inflater: LayoutInflater, theVal: Tval,
showWarning: Boolean, countryName: String
): View {
var templateId = com.ant_waters.covidstatistics.R.layout.data_cell
var text = theVal.toString()
if (theVal is Double) {
val df1 = DecimalFormat("#")
val df2 = DecimalFormat("#.0")
text = getProportionalDisplayText(text.toDouble(), df1, df2)
}
if (showWarning) { templateId = com.ant_waters.covidstatistics.R.layout.warning_data_cell }
val cellView: View = inflater.inflate(templateId, null)
val tv = cellView.findViewById<View>(com.ant_waters.covidstatistics.R.id.cell_text_view) as TextView
tv.text = text
if (DataManager.CountriesByName.containsKey(countryName)) {
cellView.setOnClickListener(View.OnClickListener {
val cpf = CountryPopupFragment()
// Supply country as an argument.
val args = Bundle()
args.putString("geoId", DataManager.CountriesByName[countryName]!!.geoId)
cpf.setArguments(args)
cpf.show(requireActivity()?.getSupportFragmentManager(), "countrypopup_from_datatable")
})
}
return cellView
}
For the rest of the code see the Github repo.