Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/4048.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
iOS MainWindows now support actions in its toolbar.
12 changes: 12 additions & 0 deletions docs/en/reference/api/mainwindow.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ self.toga.App.main_window = main_window
main_window.show()
```

## Notes

- On iOS:
- The system only utilizes the alpha channel for icons in toolbars.
- Toga does not currently provide mechanisms to access system icons, which iOS recommends, so icons should be iOS-compatible and used consistently within all scenes of your app.
- On iOS 26+, icons in different sections will have their "liquid glass" panes separated; a short distance will be inserted in previous versions.
- On iOS 26+, icons may also be moved to an overflow menu if there is not enough space to fit. It is best to avoid overflowing menus, which do not exist automatically on older systems.
- Icons are preferred to text in toolbar menus; an icon will be shown if it is provided, else the text. Icon and text will always have separated glass sections on iOS 26+.
- The iOS Human Interface Guidelines differentiate between normal and prominent actions. Any command with a text of Done, Save, or Submit will be automatically promoted to a prominent action, which has a separate glass pane on iOS 26+.
- iOS toolbar commands cannot be [disabled][toga.Command.enabled]; setting that property has no effect on toolbar behavior.


## Reference

::: toga.MainWindow
26 changes: 26 additions & 0 deletions iOS/src/toga_iOS/command.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
from rubicon.objc import NSObject, objc_method, objc_property

from toga import Command as StandardCommand

# IMPLEMENTATION NOTES:
# iOS commands are represented differently in different
# contexts, e.g. created directly in the toolbar but using
# a UICommand in the menu bar on iPad. Therefore, this
# class does not expose the creation of a native object,
# which is handled in the context of each class where
# Commands are used.

# However, places often hook up commands with a target
# and an action. Thus, an "invoker" class is defined,
# and an instance of it is exposed with each command.
# The "invoker" object should be used to hook up command
# handlers in appropriate locations.


class TogaCommandInvoker(NSObject):
interface = objc_property(object, weak=True)

@objc_method
def executeCommand_(self, sender) -> None:
self.interface.action()


class Command:
def __init__(self, interface):
self.interface = interface
self.native = []
self.invoker = TogaCommandInvoker.alloc().init()
self.invoker.interface = self.interface

@classmethod
def standard(cls, app, id):
Expand Down
10 changes: 10 additions & 0 deletions iOS/src/toga_iOS/libs/uikit.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ class UIInterfaceOrientation(Enum):
UIBarButtonItem = ObjCClass("UIBarButtonItem")


class UIBarButtonItemStyle(IntEnum):
Plain = 0
# iOS 26+
Prominent = 2
# Deprecated in iOS 8, replace by Plain
Bordered = 1
# Deprecated in iOS 26, replace by Prominent
Done = Prominent


class UIBarButtonSystemItem(Enum):
Done = 0
Cancel = 1
Expand Down
69 changes: 67 additions & 2 deletions iOS/src/toga_iOS/window.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from rubicon.objc import (
SEL,
Block,
NSPoint,
NSRect,
NSSize,
objc_id,
)

from toga.command import Separator
from toga.constants import WindowState
from toga.types import Position, Size
from toga_iOS.container import NavigationContainer, RootContainer
from toga_iOS.images import nsdata_to_bytes
from toga_iOS.libs import (
NSData,
UIBarButtonItem,
UIBarButtonItemStyle,
UIBarButtonSystemItem,
UIColor,
UIGraphicsImageRenderer,
UIImage,
Expand Down Expand Up @@ -236,6 +241,66 @@ def create_container(self):
# NavigationContainer provides a titlebar for the window.
self.container = NavigationContainer(on_refresh=self.content_refreshed)

def _create_separator(self):
return UIBarButtonItem.alloc().initWithBarButtonSystemItem(
UIBarButtonSystemItem.FixedSpace,
target=None,
action=None,
)

def create_toolbar(self):
# No toolbar handling at present
pass
bar_items = []
PROMINENT_COMMANDS = {"Done", "Save", "Submit"}
prev_group = None
for cmd in self.interface.toolbar:
if isinstance(cmd, Separator):
bar_items.append(self._create_separator())
prev_group = None
else:
if prev_group is not None and prev_group != cmd.group:
bar_items.append(self._create_separator())
prev_group = None
else:
prev_group = cmd.group

command_style = (
UIBarButtonItemStyle.Prominent
if cmd.text in PROMINENT_COMMANDS
else UIBarButtonItemStyle.Plain
)
if cmd.icon:
# 2025-01-02: The documented size for a bar button item
# is 20x20, however, that results in the icons being
# displayed too small. If you render an icon in Apple's
# SF Symbols that is fairly square using native APIs,
# and then compare them to PNG exports of SF Symbols
# rendered using _as_size(30), you'll find that it is only
# then that they render a matching size. (notes: 1, this is
# illegal for production but permitted by markups per SF
# Symbols' license, and 2, our icon handling doesn't scale pro-
# portionally, so the icon chosen for this experiment must be
# fairly square.)
# The scaling part should also be handled by the system according
# to the documentation; but it is, in fact, not, and when
# large icons are supplied, they widen the button and only
# shows the centre of the image in a 20x20 region.
bar_item = UIBarButtonItem.alloc().initWithImage(
cmd.icon._impl._as_size(30),
style=command_style,
target=cmd._impl.invoker,
action=SEL("executeCommand:"),
)
else:
bar_item = UIBarButtonItem.alloc().initWithTitle(
cmd.text,
style=command_style,
target=cmd._impl.invoker,
action=SEL("executeCommand:"),
)
bar_item.title = cmd.text
bar_items.append(bar_item)

# iOS displays in reverse to added order.
self.container.content_controller.navigationItem.rightBarButtonItems = (
bar_items[::-1]
)
31 changes: 27 additions & 4 deletions iOS/tests_backend/window.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import asyncio

import pytest

from toga_iOS.libs import UIApplication, UIWindow
from toga_iOS.libs import UIApplication, UIBarButtonSystemItem, UIWindow

from .dialogs import DialogsMixin
from .probe import BaseProbe
Expand Down Expand Up @@ -71,5 +69,30 @@ def top_bar_height(self):
def instantaneous_state(self):
return self.impl.get_window_state(in_progress_state=False)

def _toolbar_items(self):
navigation_item = self.impl.container.content_controller.navigationItem
return (
navigation_item.rightBarButtonItems.reverseObjectEnumerator().allObjects()
)

def has_toolbar(self):
pytest.skip("Toolbars not implemented on iOS")
return len(self._toolbar_items()) > 0

def assert_is_toolbar_separator(self, index, section=False):
assert (
self._toolbar_items()[index].systemItem
== UIBarButtonSystemItem.FixedSpace.value
)

def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled):
item = self._toolbar_items()[index]
assert item.title == label
# # UIKit does not expose tooltips; no assertion possible.
assert (item.image is not None) == has_icon
# # No way to disable things on UIKit; do not check for it.

def press_toolbar_button(self, index):
item = self._toolbar_items()[index]
item.target.performSelectorOnMainThread(
item.action, withObject=item, waitUntilDone=True
)
1 change: 0 additions & 1 deletion testbed/tests/app/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ async def test_main_window_toolbar(app, main_window, main_window_probe):
has_icon=True,
enabled=True,
)

# Remove the toolbar
main_window.toolbar.clear()
await main_window_probe.redraw("Main window has no toolbar")
Expand Down