Creating a table/grid with a frozen column and frozen headers
Asked Answered
S

3

19

I am working on a small Android app. Part of what I need for this android app is to have a grid that is both horizontally and vertically scroll-able. However, the leftmost column needs to be frozen (always on screen, and not part of the horizontal scrolling). Similarly, the top header row needs to be frozen (not part of the vertical scrolling)

This picture will hopefully describe this clearly if the above doesn't make too much sense:

Example of what I would like to do

Key:

  1. White: Do not scroll at all
  2. Blue: scroll vertically
  3. Red: scroll horizontally
  4. Purple: scroll both vertically and horizontally

To do one of these dimensions is easy enough, and I have done so. However, I am having trouble getting both of these dimensions to work. (i.e., I can get the bottom portion to be all blue, or I can get the right portion to be all red, but not entirely as above) The code I have is below, and will basically produce the following:

What I have!

result_grid.xml:

<RelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:background="@color/lightGrey">

    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:layout_below="@id/summaryTableLayout"
        android:layout_weight="0.1"
        android:layout_marginBottom="50dip"
        android:minHeight="100dip">
        <ScrollView
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:scrollbars="vertical">
            <LinearLayout
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">
                <TableLayout
                    android:id="@+id/frozenTable"
                    android:layout_height="wrap_content"
                    android:layout_width="wrap_content"
                    android:layout_marginTop="2dip"
                    android:layout_marginLeft="1dip"
                    android:stretchColumns="1"
                    />

                <HorizontalScrollView
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:layout_toRightOf="@id/frozenTable"
                    android:layout_marginTop="2dip"
                    android:layout_marginLeft="4dip"
                    android:layout_marginRight="1dip">

                    <TableLayout
                        android:id="@+id/contentTable"
                        android:layout_width="fill_parent"
                        android:layout_height="wrap_content"
                        android:stretchColumns="1"/>
                </HorizontalScrollView>
            </LinearLayout>
        </ScrollView>
    </LinearLayout>

    <LinearLayout
        android:layout_height="wrap_content"
        android:layout_width="fill_parent"
        android:orientation="vertical"
        android:layout_weight="0.1"
        android:layout_alignParentBottom="true">
        <Button
            android:id="@+id/backButton"
            android:layout_height="wrap_content"
            android:layout_width="fill_parent"
            android:text="Return"/>
    </LinearLayout>
</RelativeLayout>

Java code:

private boolean showSummaries;

private TableLayout summaryTable;
private TableLayout frozenTable;
private TableLayout contentTable;

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.result_grid);

    Button backButton = (Button)findViewById(R.id.backButton);
    frozenTable = (TableLayout)findViewById(R.id.frozenTable);
    contentTable = (TableLayout)findViewById(R.id.contentTable);

    ArrayList<String[]> content;

    // [Removed Code] Here I get some data from getIntent().getExtras() that will populate the content ArrayList
    PopulateMainTable(content);
}

private void PopulateMainTable(ArrayList<String[]> content) {
    // [Removed Code] There is some code here to style the table (so it has lines for the rows)

    for (int i = 0; i < content.size(); i++){
        TableRow frozenRow = new TableRow(this);
            // [Removed Code] Styling of the row
        TextView frozenCell = new TextView(this);
        frozenCell.setText(content.get(i)[0]);
        // [Removed Code] Styling of the cell
        frozenRow.addView(frozenCell);
        frozenTable.addView(frozenRow);

        // The rest of them
        TableRow row = new TableRow(this);
        // [Renoved Code] Styling of the row
        for (int j = 1; j < content.get(0).length; j++) {
            TextView rowCell = new TextView(this);
            rowCell.setText(content.get(i)[j]);
            // [Removed Code] Styling of the cell
            row.addView(rowCell);
        }

        contentTable.addView(row);
    }
}

This is what it looks like:

Look, headers!

So this is what it looks like with a little bit of horizontal scrolling

Bah, no headers!

This is what it looks like when scrolling vertically, note that you lose the headers! This is a problem!

Two last things to note!

First off, I cannot believe that this doesn't exist somewhere already. (I do not own an Android, so I have not been able to look around for apps that may do this). However, I have searched for at least two days within StackOverflow and in the Internet at large looking for a solution for either GridView or TableLayout that will provide me for what I'd like to do, and have yet to find a solution. As embarrassed as I would be for having missed it, if someone knows of a resource out there that describes how to do this, I would be grateful!

Secondly, I did try to "force" a solution to this, in that I added two LinearLayouts, one capturing the "Header" part of the grid I want to create, and another for the bottom "content" part of the grid I want to create. I can post this code, but this is already quite long and I'm hoping that what I mean is obvious. This partially worked but the problem here is that the headers and content columns were never lined up. I wanted to use getWidth() and setMinimumWidth() on the TextViews within the TableRows, but as described here this data was inaccessible during onCreate (and was also inaccessible within onPostCreate). I have been unable to find a way to get this to work, and a solution in this realm would be wonderful as well!

If you made it this far to the end, kudos to you!

Sharkskin answered 28/9, 2011 at 16:52 Comment(3)
@Kevek: "I cannot believe that this doesn't exist somewhere already" -- it may exist, but I am not aware of it being packaged as a reusable component, let alone perhaps one that is open source.Heavy
@Heavy that may very well be the case, but as I often bookmark write-ups of UI controls/algorithms that I expect to use in the future, or think are missing for a platform/language I'm hoping that maybe someone does the same, and has found this before. Maybe not!Sharkskin
I would like to note: That I did some up with a way to connect two HorizontalScrollViews views (or two ScrollViews) together so that they will always be at the same position when scrolling one of them. This means if there is a way to create a layout that would satisfy what I need with multiple parallel ScrollViews, that would be entirely possibleSharkskin
S
9

About a week ago I revisited this problem and came up with a solution. The solution requires me to do a lot of manual width setting for the columns in this grid, and I consider that to be extremely sub-par in this day and age. Unfortunately, I have also continued to look for a more well-rounded solution native to the Android platform, but I have not turned anything up.

The following is the code to create this same grid, should any one following me need it. I will explain some of the more pertinent details below!

The layout: grid.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:background="@color/lightGrey">

<TableLayout
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:layout_marginBottom="2dip"
    android:layout_weight="1"
    android:minHeight="100dip">
    <LinearLayout
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
        <TableLayout
                android:id="@+id/frozenTableHeader"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:layout_marginTop="2dip"
                android:layout_marginLeft="1dip"
                android:stretchColumns="1"
                />

        <qvtcapital.mobile.controls.ObservableHorizontalScrollView
            android:id="@+id/contentTableHeaderHorizontalScrollView"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_toRightOf="@id/frozenTableHeader"
            android:layout_marginTop="2dip"
            android:layout_marginLeft="4dip"
            android:layout_marginRight="1dip">

            <TableLayout
                android:id="@+id/contentTableHeader"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:stretchColumns="1"/>
        </qvtcapital.mobile.controls.ObservableHorizontalScrollView>
    </LinearLayout>
    <ScrollView
        android:id="@+id/verticalScrollView"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:scrollbars="vertical">
        <LinearLayout
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <TableLayout
                android:id="@+id/frozenTable"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:layout_marginTop="2dip"
                android:layout_marginLeft="1dip"
                android:stretchColumns="1"
                />

            <qvtcapital.mobile.controls.ObservableHorizontalScrollView
                android:id="@+id/contentTableHorizontalScrollView"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_toRightOf="@id/frozenTable"
                android:layout_marginTop="2dip"
                android:layout_marginLeft="4dip"
                android:layout_marginRight="1dip">

                <TableLayout
                    android:id="@+id/contentTable"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:stretchColumns="1"/>
            </qvtcapital.mobile.controls.ObservableHorizontalScrollView>
        </LinearLayout>
    </ScrollView>
</TableLayout>

The activity: Grid.java:

public class ResultGrid extends Activity implements HorizontalScrollViewListener {

private TableLayout frozenHeaderTable;
private TableLayout contentHeaderTable;
private TableLayout frozenTable;
private TableLayout contentTable;

Typeface font;
float fontSize;
int cellWidthFactor;

ObservableHorizontalScrollView headerScrollView;
ObservableHorizontalScrollView contentScrollView;

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.result_grid);

    font = Typeface.createFromAsset(getAssets(), "fonts/consola.ttf");
    fontSize = 11; // Actually this is dynamic in my application, but that code is removed for clarity
    final float scale = getBaseContext().getResources().getDisplayMetrics().density;
    cellWidthFactor = (int) Math.ceil(fontSize * scale * (fontSize < 10 ? 0.9 : 0.7));

    Button backButton = (Button)findViewById(R.id.backButton);
    frozenTable = (TableLayout)findViewById(R.id.frozenTable);
    contentTable = (TableLayout)findViewById(R.id.contentTable);
    frozenHeaderTable = (TableLayout)findViewById(R.id.frozenTableHeader);
    contentHeaderTable = (TableLayout)findViewById(R.id.contentTableHeader);
    headerScrollView = (ObservableHorizontalScrollView) findViewById(R.id.contentTableHeaderHorizontalScrollView);
    headerScrollView.setScrollViewListener(this);
    contentScrollView = (ObservableHorizontalScrollView) findViewById(R.id.contentTableHorizontalScrollView);
    contentScrollView.setScrollViewListener(this);
    contentScrollView.setHorizontalScrollBarEnabled(false); // Only show the scroll bar on the header table (so that there aren't two)

    backButton.setOnClickListener(backButtonClick);

    InitializeInitialData();
}

protected void InitializeInitialData() {
    ArrayList<String[]> content;

    Bundle myBundle = getIntent().getExtras();
    try {
        content = (ArrayList<String[]>) myBundle.get("gridData");
    } catch (Exception e) {
        content = new ArrayList<String[]>();
        content.add(new String[] {"Error", "There was an error parsing the result data, please try again"} );
        e.printStackTrace();
    }

    PopulateMainTable(content);
}

protected void PopulateMainTable(ArrayList<String[]> content) {
    frozenTable.setBackgroundResource(R.color.tableBorder);
    contentTable.setBackgroundResource(R.color.tableBorder);

    TableLayout.LayoutParams frozenRowParams = new TableLayout.LayoutParams(
            TableLayout.LayoutParams.WRAP_CONTENT,
            TableLayout.LayoutParams.WRAP_CONTENT);
    frozenRowParams.setMargins(1, 1, 1, 1);
    frozenRowParams.weight=1;
    TableLayout.LayoutParams tableRowParams = new TableLayout.LayoutParams(
            TableLayout.LayoutParams.WRAP_CONTENT,
            TableLayout.LayoutParams.WRAP_CONTENT);
    tableRowParams.setMargins(0, 1, 1, 1);
    tableRowParams.weight=1;

    TableRow frozenTableHeaderRow=null;
    TableRow contentTableHeaderRow=null;
    int maxFrozenChars = 0;
    int[] maxContentChars = new int[content.get(0).length-1];

    for (int i = 0; i < content.size(); i++){
        TableRow frozenRow = new TableRow(this);
        frozenRow.setLayoutParams(frozenRowParams);
        frozenRow.setBackgroundResource(R.color.tableRows);
        TextView frozenCell = new TextView(this);
        frozenCell.setText(content.get(i)[0]);
        frozenCell.setTextColor(Color.parseColor("#FF000000"));
        frozenCell.setPadding(5, 0, 5, 0);
        if (0 == i) { frozenCell.setTypeface(font, Typeface.BOLD);
        } else { frozenCell.setTypeface(font, Typeface.NORMAL); }
        frozenCell.setTextSize(TypedValue.COMPLEX_UNIT_DIP, fontSize);
        frozenRow.addView(frozenCell);
        if (content.get(i)[0].length() > maxFrozenChars) {
            maxFrozenChars = content.get(i)[0].length();
        }

        // The rest of them
        TableRow row = new TableRow(this);
        row.setLayoutParams(tableRowParams);
        row.setBackgroundResource(R.color.tableRows);
        for (int j = 1; j < content.get(0).length; j++) {
            TextView rowCell = new TextView(this);
            rowCell.setText(content.get(i)[j]);
            rowCell.setPadding(10, 0, 0, 0);
            rowCell.setGravity(Gravity.RIGHT);
            rowCell.setTextColor(Color.parseColor("#FF000000"));
            if ( 0 == i) { rowCell.setTypeface(font, Typeface.BOLD);
            } else { rowCell.setTypeface(font, Typeface.NORMAL); }
            rowCell.setTextSize(TypedValue.COMPLEX_UNIT_DIP, fontSize);
            row.addView(rowCell);
            if (content.get(i)[j].length() > maxContentChars[j-1]) {
                maxContentChars[j-1] = content.get(i)[j].length();
            }
        }

        if (i==0) {
            frozenTableHeaderRow=frozenRow;
            contentTableHeaderRow=row;
            frozenHeaderTable.addView(frozenRow);
            contentHeaderTable.addView(row);
        } else {
            frozenTable.addView(frozenRow);
            contentTable.addView(row);
        }
    }

    setChildTextViewWidths(frozenTableHeaderRow, new int[]{maxFrozenChars});
    setChildTextViewWidths(contentTableHeaderRow, maxContentChars);
    for (int i = 0; i < contentTable.getChildCount(); i++) {
        TableRow frozenRow = (TableRow) frozenTable.getChildAt(i);
        setChildTextViewWidths(frozenRow, new int[]{maxFrozenChars});
        TableRow row = (TableRow) contentTable.getChildAt(i);
        setChildTextViewWidths(row, maxContentChars);
    }
}

private void setChildTextViewWidths(TableRow row, int[] widths) {
    if (null==row) {
        return;
    }

    for (int i = 0; i < row.getChildCount(); i++) {
        TextView cell = (TextView) row.getChildAt(i);
        int replacementWidth =
                widths[i] == 1
                        ? (int) Math.ceil(widths[i] * cellWidthFactor * 2)
                        : widths[i] < 3
                            ? (int) Math.ceil(widths[i] * cellWidthFactor * 1.7)
                            : widths[i] < 5
                                ? (int) Math.ceil(widths[i] * cellWidthFactor * 1.2)
                                :widths[i] * cellWidthFactor;
        cell.setMinimumWidth(replacementWidth);
        cell.setMaxWidth(replacementWidth);
    }
}

public void onScrollChanged(ObservableHorizontalScrollView scrollView, int x, int y, int oldX, int oldY) {
    if (scrollView==headerScrollView) {
        contentScrollView.scrollTo(x, y);
    } else if (scrollView==contentScrollView) {
        headerScrollView.scrollTo(x, y);
    }
}

The scroll view listener (to hook the two up): HorizontalScrollViewListener.java:

public interface HorizontalScrollViewListener {
    void onScrollChanged(ObservableHorizontalScrollView scrollView, int x, int y, int oldX, int oldY);
}

The ScrollView class that implements this listener: ObservableHorizontalScrollView.java:

public class ObservableHorizontalScrollView extends HorizontalScrollView {
   private HorizontalScrollViewListener scrollViewListener=null;

   public ObservableHorizontalScrollView(Context context) {
       super(context);
   }

   public ObservableHorizontalScrollView(Context context, AttributeSet attrs, int defStyle) {
       super(context, attrs, defStyle);
   }

   public ObservableHorizontalScrollView(Context context, AttributeSet attrs) {
       super(context, attrs);
   }

   public void setScrollViewListener(HorizontalScrollViewListener scrollViewListener) {
       this.scrollViewListener = scrollViewListener;
   }

   @Override
   protected void onScrollChanged(int x, int y, int oldX, int oldY) {
       super.onScrollChanged(x, y, oldX, oldY);
       if (null!=scrollViewListener) {
           scrollViewListener.onScrollChanged(this, x, y, oldX, oldY);
       }
   }
}

The really important part of this is sort of three-fold:

  1. The ObservableHorizontalScrollView allows the header table and the content table to scroll in sync. Basically, this provides all of the horizontal motion for the grid.
  2. The way in which they stay aligned is by detecting the largest string that will be in a column. This is done at the end of PopulateMainTable(). While we're going through each of the TextViews and adding them to the rows, you'll notice that there are two arrays maxFrozenChars and maxContentChars that keep track of what the largest string value we've seen is. At the end of PopulateMainTable() we loop through each of the rows and for each of the cells we set its min and max width based on the largest string we saw in that column. This is handled by setChildTextViewWidths.
  3. The last item that makes this work is to use a monospaced font. You'll notice that in onCreate I am loading a consola.ttf font, and later applying it to each of the grid's TextViews that act as the cells in the grid. This allows us to be reasonably sure that the text will not be rendered larger than we have set the minimum and maximum width to in the prior step. I am doing a little bit of fanciness here, what with the whole cellWidthFactor and the maximum size of that column. This is really so that smaller strings will fit for sure, while we can minimize the white space for larger strings that are (for my system) not going to be all capital letters. If you ran in to trouble using this and you got strings that did not fit in the column size you set, this is where you would want to edit things. You would want to change the replacementWidth variable with some other formula for determining the cell width, such as 50 * widths[i] which would be quite large! But would leave you with a good amount of whitespace in some columns. Basically, depending on what you plan on putting in your grid, this may need to be tweaked. Above is what worked for me.

I hope this helps someone else in the future!

Sharkskin answered 17/10, 2011 at 13:33 Comment(2)
nice writeup and answer, I am facing a similar problem like yours. I have gone through your solution but could not implement it in my code. Can you please help me out with my issue? Here is the link #16141235Hospitalet
how to add vertical separarHulda
A
4

TableFixHeaders library might be useful for you in this case.

Aemia answered 13/11, 2014 at 15:51 Comment(0)
C
1

Off the top of my head, this is how I would approach this:

1) Create an interface with one method that your Activity would implement to receive scroll coordinates and that your ScrollView can call back to when a scroll occurs:

public interface ScrollCallback {
    public void scrollChanged(int newXPos, int newYPos);
}

2) Implement this in your activity to scroll the two constrained scrollviews to the position that the main scrollview just scrolled to:

@Override
public void scrollChanged(int newXPos, int newYPos) {
    mVerticalScrollView.scrollTo(0, newYPos);
    mHorizontalScrollView.scrollTo(newXPos, 0);
}

3) Subclass ScrollView to override the onScrollChanged() method, and add a method and member variable to call back to the activity:

private ScrollCallback mCallback;

//...

@Override
protected void onScrollChanged (int l, int t, int oldl, int oldt) {
    mCallback.scrollChanged(l, t);
    super.onScrollChanged(l, t, oldl, oldt);
}

public void setScrollCallback(ScrollCallback callback) {
    mCallback = callback;
}

4) Replace the stock ScrollView in your XML with your new class and call setScrollCallback(this) in onCreate().

Cofsky answered 17/10, 2011 at 9:38 Comment(4)
I just reread your question; you mention that you had a partially working solution and in the comments you say that you have an idea for an approach that might work. If you had a solution you should have posted it, it would be useful for people who come across the same problem.Cofsky
This is the partially working solution. In that, everything works, I can have two linked scroll views (horizontally) and one main scroll view, but that the alignment doesn't work. That said, I did actually come back to this problem and fix it the other day and I plan to post the solution, though I really hoped that Android would have better support for what I consider to be a very standard problem. Sadly, the platform seems lacking in this area.Sharkskin
I have some issues with implementing your code, can you please help me out with my issue? Here is the link #18440971Laciniate
how To fixed header wtth two columns in androidHulda

© 2022 - 2024 — McMap. All rights reserved.