Possible to make a beehive XML layout in Android?
android, android-layout
Solution
The latest version of this solution is too big for Stack Overflow, so I've moved it to a library and changed the focus of this post to give a basic explanation of its setup and usage. Please note that it is still meant to serve mainly as an example, though it ended up being far too complicated to do that well. If you use it in production, you should test it thoroughly.
The library includes versions for both the View framework and Compose, and both produce the same results with similar APIs. In each case, the UI component can be used as a layout for other components, a selectable hex grid with click callbacks and various draw options, or some combination of the two.
The project has a `demo` module with an app that demonstrates most of the library's features in both frameworks. In an effort to ensure that I cover everything that you need to know to use them, the bulk of this post will describe and explain the setups in each framework for both pages in the demo app, Layout and Grid.
- The Layout page demonstrates how to use these components with their basic settings to lay out children.
- The Grid page shows how the various other options available affect the hex grid's layout and draw.
Definitions
Rows and columns
You can refer to the project's README for the reasoning, but to summarize the design: rows and columns are defined as collinear cells, not necessarily contiguous ones. Furthermore, the valid coordinate pairs for a given grid differ depending on which lines are inset: the odds or the evens.
This might seem like a huge pain to deal with, but one of the library's main features is the option to display the row and/or column indices in each cell during design, so if you're using it as a layout, it's trivial to get things where they go. If you're using it as a game grid or the like, you're probably already doing some math anyway, and a minor adjustment for this should be pretty easy to incorporate.
Grid, Address and State
The library's `Grid` interface is the base for its state collections, and it defines the `Grid.Address` class to handle row and column coordinates together.
data class Address(val row: Int, val column: Int)
`Grid` itself is indexed by `Grid.Address` to access `Grid.State` objects.
data class State(val isVisible: Boolean, val isSelected: Boolean)
Though I often talk about grid cells in descriptions and code, I've intentionally avoided creating a `Cell(Address, State)` type, because things start to get unwieldy, redundant and confusing when one is introduced. When `Address`es and `State`s need to be handled together, they're passed as `Pair`s or in `Map`s, to help enforce the idea that only the values matter, not the particular instances.
Only a certain set of `Grid.Address`es is valid for a given `Grid` definition. The custom `forEach()` function can be used to iterate over them, and there are a couple of other functions to check if an arbitrary address is valid.
Specific examples of each version's `Grid` implementation are to be found in the relevant Layout page sections that follow.
Layout page
The first page shows the two versions – View and Compose – of a 3x5 (rows x columns) grid with even lines inset, and each cell holds a shaped container component, with an image component centered inside.
The cells are clickable, and doing so toggles their selected state. The switches in the center group control a few different properties in order to give you an idea of the various designs possible when using it as a layout. The radio buttons show the various options for positioning the grid with respect to the vertical sizes, this grid's `CrossMode`.
Layout page: Views
The following snippets are from `LayoutFragment` in the demo app, though they've been altered a bit for this discussion. Obviously, `hexGridView` is a `HexGridView`. In the demo app, it's handled through View binding of an XML layout, though it can be instantiated and declared in any of the usual ways.
The View version of `Grid` is `MutableGrid`, and it's similar to a `MutableMap<Grid.Address, Grid.State>`, though the only thing that's actually mutable is that the `Grid.State` instances can be changed out. Creating an instance is simple, and setting it on the `HexGridView` is all that's needed to get it to draw the grid.
val mutableGrid = MutableGrid(3, 5, insetEvenLines = true)
hexGridView.grid = mutableGrid
Adding `View`s in code should be done with a `HexGridView.ViewProvider`, which is a functional interface that's called for each `Grid.Address` during layout, similar to an `Adapter` for a `ListView` or `RecyclerView`. The demo's `ViewProvider` creates items that are `FrameLayout`s with `ImageView`s centered inside, handled through View binding, which you can see in the `when` at the start:
hexGridView.viewProvider = HexGridView.ViewProvider { address, current ->
val item = when {
current != null -> LayoutItemBinding.bind(current)
else -> LayoutItemBinding.inflate(
layoutInflater,
hexGridView,
false
)
}
if (showBackgrounds) {
hexGridView.applyHexBackground(
item.root,
colorFor(address).toArgb(),
resources.getDimension(R.dimen.hex_inset)
)
} else {
hexGridView.removeHexBackground(item.root)
}
item.image.isVisible = showIcons
item.root
}
The `showBackgrounds` value is set by the corresponding switch in the layout, and here it controls whether the root `FrameLayout`s have a `HexDrawable` set as their backgrounds with `applyHexBackground()`, or removed with the complementary function. `HexDrawable` works with `HexGridView` to set its `Outline` to the exact same hexagon as the current grid cell shape, with an optional inset, allowing the children to align exactly and to cast appropriately shaped shadows.
Though it's not shown here, when `showBackgrounds` or `showIcons` are updated in the demo app, `notifyViewsInvalidated()` is called on `hexGridView` to to refresh the `View`s and call the `ViewProvider` for each cell all over again.
The `item.image` visibility is rather self-explanatory, and you can see that the binding's `item.root` is returned from the lambda as the cell's `View`. If null is returned instead, any `View` that may have been previously set for the given `address` is removed.
The demo grid's interactivity is limited to toggling a cell's selected state upon clicking, and that's done with the `toggle()` extension function that simply replaces the `Grid.State` at `Grid.Address` with one that has the opposite value. Like `ListView` and `RecyclerView`, when the data set is modified externally, the `HexGridView` must be notified. Changing a `Grid.State` only requires a redraw, and it's sufficient to call `invalidate()` on the `HexGridView` to do that.
hexGridView.onClickListener = HexGridView.OnClickListener { address ->
mutableGrid.toggle(address)
hexGridView.invalidate()
}
As for the rest of the controls in the center layout:
- The Stroke switch toggles the `HexGridView`'s `strokeColor` property between `BLACK` and `TRANSPARENT`.
- The radio buttons update the `HexGridView`'s `CrossMode`, which determines how this grid is laid out vertically with respect to its containing component.
XML setup
Lastly here, `HexGridView` can be set up entirely through layout XML, and it recognizes several custom attributes for that purpose on its own tag and its children's tags. The following minimal snippet gives examples of how to effect the same grid and layout settings as the runtime code shown above:
<com.gonodono.hexgrid.view.HexGridView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cellFillColor="@color/light_gray"
app:cellIndicesShown="rows|columns"
app:gridRowCount="5"
app:gridColumnCount="3">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="@dimen/hex_elevation"
android:src="@drawable/ic_android"
app:layout_cellRowAndColumn="1,1"
app:layout_cellIsVisible="false"
app:layout_hexBackgroundEnabled="true"
app:layout_hexBackgroundColor="@color/blue"
app:layout_hexBackgroundInset="@dimen/hex_inset" />
<com.gonodono.hexgrid.view.CellStateView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_cellRowAndColumn="3,1"
app:layout_cellIsSelected="true" />
</com.gonodono.hexgrid.view.HexGridView>
That trimmed-down sample will produce the following, both at runtime and in Android Studio's layout editor:
As you might notice, the library's `CellStateView` doesn't add an actual `View` to the `HexGridView`. It simply serves to set the given state values on the `Grid`. The inflated `View` instance is discarded, similar to how `TabLayout`'s `TabItem` works.
Layout page: Compose
Compose's version of `Grid` is `ImmutableGrid`. It's appropriately annotated `@Immutable`, and it has all of the same properties and functions as `MutableGrid`, except for the mutating `set` operator.
var immutableGrid by remember {
mutableStateOf(ImmutableGrid(3, 5, insetEvenLines = true))
}
Everything from the View version above translates to a similar operation or setting in Compose. The three color options are packaged into a simple `data class` – handled through `HexGridDefaults` – and `showStroke` again just toggles the grid's `strokeColor` between `Black` and `Transparent`.
val strokeColor = when {
showStroke -> Color.Black
else -> Color.Transparent
}
HexGrid(
grid = immutableGrid,
crossMode = crossMode,
colors = HexGridDefaults.colors(strokeColor = strokeColor),
onGridTap = { address ->
immutableGrid = immutableGrid.toggled(address)
}
) { address ->
val color = when {
showBackgrounds -> colorFor(address)
else -> Color.Transparent
}
val elevation = when {
showBackgrounds -> dimensionResource(R.dimen.hex_elevation)
else -> 0.dp
}
val shape = when {
showBackgrounds -> getHexShape(dimensionResource(R.dimen.hex_inset))
else -> RectangleShape
}
Surface(
color = color,
elevation = elevation,
shape = shape,
modifier = Modifier.fillMaxSize()
) {
if (showIcons) Image(
painter = painterResource(R.drawable.ic_android),
contentDescription = "Icon",
alignment = Alignment.Center
)
}
}
The `onGridTap` function is `HexGrid`'s click callback, and this one also just toggles the cell's selected state, though that's done functionally here due to the immutable grid. In keeping with standard conventions, the `toggled()` extension for `Grid` returns a new instance with the state change applied, instead of changing out the `Grid.State` in place like `toggle()` does for `MutableGrid`.
`HexGrid`'s final parameter is the (optional) `HexGridItemScope`, in which individual cell items are emitted. The scope exposes the `getHexShape(inset: Dp): Shape` function that returns a `GenericShape` of the exact same hexagon that the grid is using, with an optional inset, in order to shape the child Composables, if needed.
In that scope here, the demo's color, elevation, and shape are figured per `showBackgrounds` to create the same effects that the View's version does with a background drawable, and the `showIcons` value determines whether the icon `Image`s are emitted.
Grid page
The second demo page contains a panel that flips between a `HexGridView`, a `ComposeView` with a `HexGrid`, and a plain `View` with a `HexGridDrawable` background, in order to demonstrate the various grid options and how they produce the same results everywhere. (Btw, there's a `HexGridDrawable` class, too, that I've neglected to mention so far.)
All three implementations share the same state which starts with a 5x5 grid with even lines inset, and two interior cells selected:
MutableGrid(
rowCount = 5,
columnCount = 5,
insetEvenLines = true,
initial = mapOf(
Address(2, 1) to State(isSelected = true),
Address(2, 3) to State(isSelected = true)
)
)
The `initial` parameter takes a `Map<Grid.Address, Grid.State>`. In this example, `Address` and `State` are both imported directly, to keep it relatively short. Eventually there will be a more concise way to create and/or pass initial states, but this will have to suffice for now.
Again, the cells are clickable to toggle their selected states (except for the Drawable version), but those states are cleared if you change the grid shape; i.e., if you change the row count, the column count, which lines are inset, or whether edge lines are enabled. Changing any of those causes a new `Grid` instance to be created.
In addition to those grid shape settings, every other layout and draw option is available to fiddle with in order to observe the effects.
- `FitMode`: Whether the hex size is determined by columns or rows: `FitColumns` or `FitRows`.
- `CrossMode`: How to lay out the other direction: `AlignCenter`, `AlignStart`, `AlignEnd`, or `ScaleToFit`.
- `HexOrientation`: Which orientation a hexagon's major axis aligns with: `Horizontal` or `Vertical`.
- `strokeWidth`: The thickness of the hex outline.
- `strokeColor`: The color of the hex outline.
- `fillColor`: The normal fill color for each cell.
- `selectColor`: The fill color for a cell when it's selected.
- `showRowIndices`: Whether to display the row indices in each cell.
- `showColumnIndices`: Whether to display the column indices in each cell.
Grid page: Views
All of the listed settings have direct `var` properties in both `HexGridView` and `HexGridDrawable` that will automatically refresh the grid if changed.
The drawable class is quite similar to the other two versions, but it doesn't support cell content, and it's not inherently interactive. It could be useful as background decoration, for example, or wherever else you might want to put a grid that only takes a `Drawable`.
Grid page: Compose
`HexGrid` handles its option values a little differently.
fun HexGrid(
…
colors: HexGridColors = HexGridDefaults.colors(),
indicesShown: IndicesShown = HexGridDefaults.indicesShown(),
…
)
In keeping with standard Compose design, the colors are grouped into their own class, though they're not stateful yet, as are the flag values for showing row and/or column indices.
Download
If you plan to makes changes, obviously you'll have to clone the repo, but if it happens to have everything you need already, I've configured it to be published, so you can get it pre-compiled through the very handy service JitPack. The repo's page is here, and it has instructions toward the bottom on how to add JitPack and the library's dependency to your project. All four modules are published and show under the "Subprojects" drop-down, but you only need to grab `view` or `compose`.
Notes
I've not covered every last little thing available in the API here nor in the README yet, but the code docs have been published through github.io.
The Issues feature is enabled on the GitHub repo, so please use that for any problems with the library itself.
Problem
A beehive layout should look like this The colored hives are just so you understand how I have to lay down elements. Which `Layout` widget do you suggest of using? I tried with `GridView` but I cannot make such cells, then `FrameLayout` but don't want to deal with pixel (or dp) values when setting hive location. I am at my wits end. I am close to conclusion that something like this cannot be done in Android in a high quality way and without using game-like libraries. I hope someone will give me a good clue to solution.