Skip to content
Draft
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/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