Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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/4275.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On iOS 18-, system effects are applied to the title bar when a ScrollContainer is the content of the window and reaches the titlebar area; on iOS 26+, system effects are consistently applied to the title bar when a ScrollContainer simply reaches the top of the window container layout.
2 changes: 1 addition & 1 deletion cocoa/src/toga_cocoa/widgets/scrollcontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def refreshContent(self):
# This cannot be covered in CI because the function used to emit
# a scrolling event is unreliable.
@objc_method
def wantsForwardedScrollEventsForAxis_(self, axis: int) -> None: # pragma: no cover
def wantsForwardedScrollEventsForAxis_(self, axis: int) -> bool: # pragma: no cover
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by fix.

return True


Expand Down
1 change: 1 addition & 0 deletions examples/scrollcontainer/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ requires = [
"../../iOS",
"std-nslog>=1.0.0",
]
info."UIDesignRequiresCompatibility" = true

[tool.briefcase.app.scrollcontainer.android]
requires = [
Expand Down
37 changes: 27 additions & 10 deletions examples/scrollcontainer/scrollcontainer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ class ScrollContainerApp(toga.App):
TOGGLE_CHUNK = 10

def startup(self):
main_box = toga.Box(direction=COLUMN)

self.hswitch = toga.Switch(
"Horizontal",
value=False,
Expand All @@ -46,10 +44,6 @@ def startup(self):
value=False,
on_change=lambda widget: self.update_content(),
)
main_box.add(
toga.Box(children=[self.hswitch, self.vswitch]),
toga.Box(children=[self.wide_switch, self.tall_switch, self.nested_switch]),
)

self.inner_box = toga.Box(
direction=COLUMN, margin=10, background_color="yellow"
Expand All @@ -59,16 +53,22 @@ def startup(self):
vertical=self.vswitch.value,
on_scroll=self.on_scroll,
flex=1,
margin=10,
background_color="pink",
)
self.button = toga.Button("Toggle Controls", on_press=self.on_control_press)
self.update_content()

self.scroller.content = self.inner_box
main_box.add(self.scroller)

self.main_box = toga.Box(direction=COLUMN, margin_bottom=10)
self.main_box.add(self.scroller)
self.main_box.add(
toga.Box(children=[self.hswitch, self.vswitch]),
toga.Box(children=[self.wide_switch, self.tall_switch, self.nested_switch]),
)

self.main_window = toga.MainWindow(size=(400, 700))
self.main_window.content = main_box
self.main_window.content = self.main_box
self.main_window.show()

self.commands.add(
Expand Down Expand Up @@ -102,6 +102,20 @@ def startup(self):
),
)

def on_control_press(self, widget):
if self.main_window.content != self.scroller:
self.main_box.clear()
self.main_window.content = self.scroller
else:
self.main_window.content = self.main_box
self.main_box.add(self.scroller)
self.main_box.add(
toga.Box(children=[self.hswitch, self.vswitch]),
toga.Box(
children=[self.wide_switch, self.tall_switch, self.nested_switch]
),
)

def handle_hscrolling(self, widget):
self.scroller.horizontal = self.hswitch.value

Expand All @@ -110,14 +124,17 @@ def handle_vscrolling(self, widget):

def update_content(self):
self.inner_box.clear()
self.inner_box.add(self.button)

width = 10 if self.wide_switch.value else 2
height = 30 if self.tall_switch.value else 2
for x in range(height):
label_text = f"Label {x}"
if self.nested_switch.value:
self.inner_box.add(
toga.ScrollContainer(content=Item(width, label_text)),
toga.ScrollContainer(
content=Item(width, label_text), vertical=False
),
)
else:
self.inner_box.add(Item(width, label_text))
Expand Down
5 changes: 5 additions & 0 deletions iOS/src/toga_iOS/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def __init__(
self.bottom_inset = 0
self.right_inset = 0

self.top_unset = False
self.left_unset = False
self.bottom_unset = False
self.right_unset = False

def __del__(self):
# Mark the contained native object as explicitly None so that the
# constraints know the object has been deleted.
Expand Down
1 change: 1 addition & 0 deletions iOS/src/toga_iOS/libs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from .mapkit import * # NOQA
from .uikit import * # NOQA
from .webkit import * # NOQA
from .liquidglass import * # noqa: F401, F403
28 changes: 28 additions & 0 deletions iOS/src/toga_iOS/libs/core_foundation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from ctypes import c_bool, c_char_p, c_uint32, c_void_p, cdll, util

cf = cdll.LoadLibrary(util.find_library("CoreFoundation"))

kCFStringEncodingUTF8 = 134217984

CFBundleRef = c_void_p
CFURLRef = c_void_p
CFStringRef = c_void_p


cf.CFBundleGetMainBundle.restype = CFBundleRef
cf.CFBundleGetMainBundle.argtypes = []

cf.CFBundleCopyBundleURL.restype = CFURLRef
cf.CFBundleCopyBundleURL.argtypes = [CFBundleRef]

cf.CFBundleCopyInfoDictionaryForURL.restype = c_void_p
cf.CFBundleCopyInfoDictionaryForURL.argtypes = [CFURLRef]

cf.CFDictionaryGetValue.restype = c_void_p
cf.CFDictionaryGetValue.argtypes = [c_void_p, c_void_p]

cf.CFStringCreateWithCString.restype = CFStringRef
cf.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32]

cf.CFBooleanGetValue.restype = c_bool
cf.CFBooleanGetValue.argtypes = [c_void_p]
29 changes: 29 additions & 0 deletions iOS/src/toga_iOS/libs/liquidglass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from platform import ios_ver

from .core_foundation import cf, kCFStringEncodingUTF8
from .sdk_version import get_sdk_version

bundle = cf.CFBundleGetMainBundle()
url = cf.CFBundleCopyBundleURL(bundle)
info_dict = cf.CFBundleCopyInfoDictionaryForURL(url)
key_cf = cf.CFStringCreateWithCString(
None, b"UIDesignRequiresCompatibility", kCFStringEncodingUTF8
)
value_cf = cf.CFDictionaryGetValue(info_dict, key_cf)
if value_cf is None:
value_py = None
else:
value_py = bool(cf.CFBooleanGetValue(value_cf))
supports_liquid_glass = False
if value_py is True:
supports_liquid_glass = False
elif value_py is False:
supports_liquid_glass = True
else:
supports_liquid_glass = int(get_sdk_version().split(".")[0]) >= 26

supports_liquid_glass = (
supports_liquid_glass and int(ios_ver().release.split(".")[0]) >= 26
)

__all__ = ["supports_liquid_glass"]
87 changes: 87 additions & 0 deletions iOS/src/toga_iOS/libs/sdk_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import ctypes

MH_MAGIC = 0xFEEDFACE
MH_MAGIC_64 = 0xFEEDFACF

LC_BUILD_VERSION = 0x32


class MachHeader32(ctypes.Structure):
_fields_ = [
("magic", ctypes.c_uint32),
("cputype", ctypes.c_uint32),
("cpusubtype", ctypes.c_uint32),
("filetype", ctypes.c_uint32),
("ncmds", ctypes.c_uint32),
("sizeofcmds", ctypes.c_uint32),
("flags", ctypes.c_uint32),
]


class MachHeader64(ctypes.Structure):
_fields_ = [
("magic", ctypes.c_uint32),
("cputype", ctypes.c_uint32),
("cpusubtype", ctypes.c_uint32),
("filetype", ctypes.c_uint32),
("ncmds", ctypes.c_uint32),
("sizeofcmds", ctypes.c_uint32),
("flags", ctypes.c_uint32),
("reserved", ctypes.c_uint32),
]


class LoadCommand(ctypes.Structure):
_fields_ = [
("cmd", ctypes.c_uint32),
("cmdsize", ctypes.c_uint32),
]


class BuildVersionCommand(ctypes.Structure):
_fields_ = [
("cmd", ctypes.c_uint32),
("cmdsize", ctypes.c_uint32),
("platform", ctypes.c_uint32),
("minos", ctypes.c_uint32),
("sdk", ctypes.c_uint32),
("ntools", ctypes.c_uint32),
]


libc = ctypes.CDLL(None)
_dyld_get_image_header = libc._dyld_get_image_header
_dyld_get_image_header.restype = ctypes.c_void_p
_dyld_get_image_header.argtypes = [ctypes.c_uint32]


def decode_version(v):
return f"{v >> 16}.{(v >> 8) & 0xFF}.{v & 0xFF}"


def get_sdk_version():
mh_ptr = _dyld_get_image_header(0)
magic = ctypes.c_uint32.from_address(mh_ptr).value

if magic == MH_MAGIC_64:
header = MachHeader64.from_address(mh_ptr)
cmd_ptr = mh_ptr + ctypes.sizeof(MachHeader64)
elif magic == MH_MAGIC:
header = MachHeader32.from_address(mh_ptr)
cmd_ptr = mh_ptr + ctypes.sizeof(MachHeader32)
else:
return None

for _ in range(header.ncmds):
lc = LoadCommand.from_address(cmd_ptr)

if lc.cmd == LC_BUILD_VERSION:
cmd = BuildVersionCommand.from_address(cmd_ptr)
return decode_version(cmd.sdk)

cmd_ptr += lc.cmdsize

return None


__all__ = ["get_sdk_version"]
33 changes: 32 additions & 1 deletion iOS/src/toga_iOS/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from toga_iOS.colors import native_color
from toga_iOS.constraints import Constraints

from ..libs import supports_liquid_glass


class Widget:
def __init__(self, interface):
Expand All @@ -22,6 +24,14 @@ def __init__(self, interface):
# #3104 for details.
self._default_background_color = self.native.backgroundColor

@property
def scroll_vertical(self):
return False

@property
def scroll_horizontal(self):
return False

@abstractmethod
def create(self): ...

Expand Down Expand Up @@ -78,7 +88,28 @@ def set_tab_index(self, tab_index):
# APPLICATOR

def set_bounds(self, x, y, width, height):
# print("SET BOUNDS", self, x, y, width, height, self.container.top_offset)
# print("SET BOUNDS", self, x, y, width, height, self.container.top_inset)
if supports_liquid_glass or self.container.content == self:
if self.scroll_vertical and self.container.top_unset and y == 0:
y -= self.container.top_inset
height += self.container.top_inset
# Right now, the below lines are irrelevant and lead to coverage issues,
# as we only have a title bar to bleed over. When constructs such as
# if (
# self.scroll_vertical
# and self.container.bottom_unset
# and y + height == self.container.height
# ):
# height += self.container.bottom_inset
# if self.scroll_horizontal and self.container.left_unset and x == 0:
# x -= self.container.left_inset
# width += self.container.left_inset
# if (
# self.scroll_horizontal
# and self.container.right_unset
# and x + width == self.container.width
# ):
# width += self.container.right_inset
self.constraints.update(x, y, width, height)

def set_text_align(self, alignment):
Expand Down
10 changes: 10 additions & 0 deletions iOS/src/toga_iOS/widgets/scrollcontainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def get_vertical(self):

def set_vertical(self, value):
self._allow_vertical = value
self.native.alwaysBounceVertical = value
# If the scroll container has content, we need to force a refresh
# to let the scroll container know how large its content is.
if self.interface.content:
Expand All @@ -102,8 +103,17 @@ def set_vertical(self, value):
def get_horizontal(self):
return self._allow_horizontal

@property
def scroll_vertical(self):
return self._allow_vertical

@property
def scroll_horizontal(self):
return self._allow_horizontal

def set_horizontal(self, value):
self._allow_horizontal = value
self.native.alwaysBounceHorizontal = value
# If the scroll container has content, we need to force a refresh
# to let the scroll container know how large its content is.
if self.interface.content:
Expand Down
2 changes: 2 additions & 0 deletions iOS/src/toga_iOS/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ def create_container(self):
on_refresh=self.content_refreshed,
on_native_layout=self.content_native_layout,
)
# Widgets can extend into the top space with safe blurring.
self.container.top_unset = True

def content_native_layout(self, container):
# Instead of manually computing the geometry at the top,
Expand Down
Loading