Skip to content

Commit

Permalink
Merge pull request #2350 from freakboy3742/android-optioncontainer
Browse files Browse the repository at this point in the history
Android optioncontainer
  • Loading branch information
mhsmith authored Jan 31, 2024
2 parents e2b6e71 + 3fcf169 commit e6db06e
Show file tree
Hide file tree
Showing 29 changed files with 542 additions and 137 deletions.
6 changes: 3 additions & 3 deletions android/src/toga_android/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ def clear_content(self):
def resize_content(self, width, height):
if (self.native_width, self.native_height) != (width, height):
self.native_width, self.native_height = (width, height)
if self.interface.content:
self.interface.content.refresh()
if self.content:
self.content.interface.refresh()

def refreshed(self):
# We must use the correct LayoutParams class, but we don't know what that class
# is, so reuse the existing object. Calling the constructor of type(lp) is also
# an option, but would probably be less safe because a subclass might change the
# meaning of the (int, int) constructor.
lp = self.native_content.getLayoutParams()
layout = self.interface.content.layout
layout = self.content.interface.layout
lp.width = max(self.native_width, self.scale_in(layout.width))
lp.height = max(self.native_height, self.scale_in(layout.height))
self.native_content.setLayoutParams(lp)
Expand Down
3 changes: 2 additions & 1 deletion android/src/toga_android/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .widgets.label import Label
from .widgets.multilinetextinput import MultilineTextInput
from .widgets.numberinput import NumberInput
from .widgets.optioncontainer import OptionContainer
from .widgets.passwordinput import PasswordInput
from .widgets.progressbar import ProgressBar
from .widgets.scrollcontainer import ScrollContainer
Expand Down Expand Up @@ -55,7 +56,7 @@ def not_implemented(feature):
"Label",
"MultilineTextInput",
"NumberInput",
# "OptionContainer",
"OptionContainer",
"PasswordInput",
"ProgressBar",
"ScrollContainer",
Expand Down
16 changes: 15 additions & 1 deletion android/src/toga_android/icons.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from android.graphics import BitmapFactory
from android.graphics import Bitmap, BitmapFactory, Rect
from android.graphics.drawable import BitmapDrawable


class Icon:
Expand All @@ -13,3 +14,16 @@ def __init__(self, interface, path):
self.native = BitmapFactory.decodeFile(str(path))
if self.native is None:
raise ValueError(f"Unable to load icon from {path}")

def as_drawable(self, widget, size):
bitmap = Bitmap.createScaledBitmap(
self.native,
widget.scale_in(size),
widget.scale_in(size),
True,
)
drawable = BitmapDrawable(widget.native.getContext().getResources(), bitmap)
drawable.setBounds(
Rect(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight())
)
return drawable
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 2 additions & 13 deletions android/src/toga_android/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from decimal import ROUND_UP

from android.graphics import Bitmap, Rect
from android.graphics.drawable import BitmapDrawable
from android.view import View
from android.widget import Button as A_Button
from java import dynamic_proxy
Expand Down Expand Up @@ -41,17 +39,8 @@ def get_icon(self):
def set_icon(self, icon):
self._icon = icon
if icon:
# Scale icon to to a 48x48 CSS pixel bitmap.
bitmap = Bitmap.createScaledBitmap(
icon._impl.native,
self.scale_in(48),
self.scale_in(48),
True,
)
drawable = BitmapDrawable(self.native.getContext().getResources(), bitmap)
drawable.setBounds(
Rect(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight())
)
# Scale icon to to a 48x48 CSS pixel bitmap drawable.
drawable = icon._impl.as_drawable(self, 48)
else:
drawable = None

Expand Down
226 changes: 226 additions & 0 deletions android/src/toga_android/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
from __future__ import annotations

import warnings
from dataclasses import dataclass

from android.view import MenuItem
from android.widget import LinearLayout

try:
from com.google.android.material.bottomnavigation import BottomNavigationView
from com.google.android.material.navigation import NavigationBarView
except ImportError: # pragma: no cover
# If you've got an older project that doesn't include the Material library,
# this import will fail. We can't validate that in CI, so it's marked no cover
BottomNavigationView = None
NavigationBarView = None

from java import dynamic_proxy
from travertino.size import at_least

import toga

from ..container import Container
from .base import Widget


@dataclass
class TogaOption:
text: str
icon: toga.Icon
widget: Widget
enabled: bool = True
menu_item: MenuItem | None = None


if NavigationBarView is not None: # pragma: no branch

class TogaOnItemSelectedListener(
dynamic_proxy(NavigationBarView.OnItemSelectedListener)
):
def __init__(self, impl):
super().__init__()
self.impl = impl

def onNavigationItemSelected(self, item):
for index, option in enumerate(self.impl.options):
if option.menu_item == item:
self.impl.set_current_tab_index(index, programmatic=False)
return True

# You shouldn't be able to select an item that isn't isn't selectable.
return False # pragma: no cover


class OptionContainer(Widget, Container):
uses_icons = True

def create(self):
if BottomNavigationView is None: # pragma: no cover
raise RuntimeError(
"Unable to import BottomNavigationView. Ensure that the Material "
"system package (com.google.android.material:material:1.11.0) "
"is listed in your app's dependencies."
)

self.native = LinearLayout(self._native_activity)
self.native.setOrientation(LinearLayout.VERTICAL)

# Define layout parameters for children; expand to fill,
self.init_container(self.native)
self.native_content.setLayoutParams(
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
1, # weight 1; child content should expand
)
)

# Add the navigation bar
self.native_navigationview = BottomNavigationView(self._native_activity)
self.native_navigationview.setLabelVisibilityMode(
BottomNavigationView.LABEL_VISIBILITY_LABELED
)
self.max_items = self.native_navigationview.getMaxItemCount()

self.native.addView(
self.native_navigationview,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
0, # weight 0; it shouldn't expand
),
)

self.onItemSelectedListener = TogaOnItemSelectedListener(self)
self.native_navigationview.setOnItemSelectedListener(
self.onItemSelectedListener
)

self.options = []

def set_bounds(self, x, y, width, height):
super().set_bounds(x, y, width, height)
lp = self.native.getLayoutParams()
super().resize_content(
lp.width, lp.height - self.native_navigationview.getHeight()
)

def purge_options(self):
for option in self.options:
option.menu_item = None
self.native_navigationview.getMenu().clear()

def rebuld_options(self):
for index, option in enumerate(self.options):
if index < self.max_items:
option.menu_item = self.native_navigationview.getMenu().add(
0, 0, index, option.text
)
self.set_option_icon(index, option.icon)
self.set_option_enabled(index, option.enabled)

def add_option(self, index, text, widget, icon=None):
# Store the details of the new option
option = TogaOption(text=text, icon=icon, widget=widget)
self.options.insert(index, option)

# Create a menu item for the tab
if index >= self.max_items:
warnings.warn(
f"OptionContainer is limited to {self.max_items} items on "
"Android. Additional item will be ignored."
)
option.menu_item = None
else:
if len(self.options) > self.max_items:
warnings.warn(
f"OptionContainer is limited to {self.max_items} items on "
"Android. Excess items will be ignored."
)
last_option = self.options[self.max_items - 1]
self.native_navigationview.getMenu().removeItem(
last_option.menu_item.getItemId()
)
last_option.menu_item = None

# Android doesn't let you change the order index of an item after it has been
# created, which means there's no way to insert an item into an existing
# ordering. As a workaround, rebuild the entire navigation menu on every
# insertion.
self.purge_options()
self.rebuld_options()

# If this is the only option, make sure the content is selected
if len(self.options) == 1:
self.set_current_tab_index(0)

def remove_option(self, index):
# Android doesn't let you change the order index of an item after it has been
# created, which means there's no way to insert an item into an existing
# ordering. If an item is deleted, rebuild the entire navigation menu.
self.purge_options()
del self.options[index]
self.rebuld_options()

def set_option_enabled(self, index, enabled):
option = self.options[index]
option.enabled = enabled
if option.menu_item:
option.menu_item.setEnabled(enabled)

def is_option_enabled(self, index):
option = self.options[index]
if option.menu_item:
return option.menu_item.isEnabled()
else:
return option.enabled

def set_option_text(self, index, text):
option = self.options[index]
option.text = text
if option.menu_item:
option.menu_item.setTitle(text)

def get_option_text(self, index):
option = self.options[index]
if option.menu_item:
return option.menu_item.getTitle()
else:
return option.text

def set_option_icon(self, index, icon):
option = self.options[index]
option.icon = icon

if option.menu_item:
if icon is None:
icon = toga.Icon.OPTION_CONTAINER_DEFAULT_TAB_ICON

drawable = icon._impl.as_drawable(self, 32)
option.menu_item.setIcon(drawable)

def get_option_icon(self, index):
return self.options[index].icon

def get_current_tab_index(self):
for index, option in enumerate(self.options):
if option.menu_item.isChecked():
return index
# One of the tabs has to be selected
return None # pragma: no cover

def set_current_tab_index(self, index, programmatic=True):
if index < self.max_items:
option = self.options[index]
self.set_content(option.widget)
option.widget.interface.refresh()
if programmatic:
option.menu_item.setChecked(True)
self.interface.on_select()
else:
warnings.warn("Tab is outside selectable range")

def rehint(self):
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT)
44 changes: 44 additions & 0 deletions android/tests_backend/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from android.widget import LinearLayout
from com.google.android.material.bottomnavigation import BottomNavigationView

from .base import SimpleProbe


class OptionContainerProbe(SimpleProbe):
native_class = LinearLayout
disabled_tab_selectable = False
max_tabs = 5

def __init__(self, widget):
super().__init__(widget)
self.native_navigationview = widget._impl.native_navigationview
assert isinstance(self.native_navigationview, BottomNavigationView)

def select_tab(self, index):
item = self.native_navigationview.getMenu().getItem(index)
# Android will let you programmatically select a disabled tab.
if item.isEnabled():
item.setChecked(True)
self.impl.onItemSelectedListener(item)

def tab_enabled(self, index):
return self.native_navigationview.getMenu().getItem(index).isEnabled()

def assert_tab_icon(self, index, expected):
actual = self.impl.options[index].icon
if expected is None:
assert actual is None
else:
assert actual.path.name == expected
assert actual._impl.path.name == f"{expected}-android.png"

def assert_tab_content(self, index, title, enabled):
# Get the actual menu items, and sort them by their order index.
# This *should* match the actual option order.
menu_items = sorted(
[option.menu_item for option in self.impl.options if option.menu_item],
key=lambda m: m.getOrder(),
)

assert menu_items[index].getTitle() == title
assert menu_items[index].isEnabled() == enabled
1 change: 1 addition & 0 deletions changes/2346.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An OptionContainer widget was added for Android.
4 changes: 2 additions & 2 deletions cocoa/src/toga_cocoa/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def content_refreshed(self, container):
container.min_width = container.content.interface.layout.min_width
container.min_height = container.content.interface.layout.min_height

def add_content(self, index, text, widget, icon):
def add_option(self, index, text, widget, icon):
# Create the container for the widget
container = Container(on_refresh=self.content_refreshed)
container.content = widget
Expand All @@ -81,7 +81,7 @@ def add_content(self, index, text, widget, icon):
item.view = container.native
self.native.insertTabViewItem(item, atIndex=index)

def remove_content(self, index):
def remove_option(self, index):
tabview = self.native.tabViewItemAtIndex(index)
self.native.removeTabViewItem(tabview)

Expand Down
1 change: 1 addition & 0 deletions cocoa/tests_backend/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

class OptionContainerProbe(SimpleProbe):
native_class = NSTabView
max_tabs = None
disabled_tab_selectable = False

# 2023-06-20: This makes no sense, but here we are. If you render an NSTabView with
Expand Down
Loading

0 comments on commit e6db06e

Please sign in to comment.