@rdlabo/ionic-theme-ios27 0.1.0-beta.pr154.shac3e35365120e → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/css/components/ion-action-sheet.css +1 -1
- package/dist/css/components/ion-alert.css +1 -1
- package/dist/css/components/ion-button.css +1 -1
- package/dist/css/components/ion-card.css +1 -1
- package/dist/css/components/ion-fab.css +1 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/components/ion-modal.css +1 -1
- package/dist/css/components/ion-popover.css +1 -1
- package/dist/css/components/ion-range.css +1 -1
- package/dist/css/components/ion-segment.css +1 -1
- package/dist/css/components/ion-tabs.css +1 -1
- package/dist/css/components/ion-toast.css +1 -1
- package/dist/css/components/ion-toolbar.css +1 -1
- package/dist/css/default-variables.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-always.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-class.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-system.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/md-ion-list-inset.css +1 -1
- package/dist/css/utils/dark/ion-list.css +1 -0
- package/dist/css/utils/group-heading.css +1 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +9 -1
- package/dist/native/definitions.d.ts +4 -0
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +51 -27
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +17 -0
- package/dist/native/shared/crossfade.d.ts +8 -0
- package/dist/native/shared/crossfade.d.ts.map +1 -0
- package/dist/native/shared/crossfade.js +46 -0
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +2 -1
- package/dist/popover/animations/ios.enter.d.ts.map +1 -1
- package/dist/popover/animations/ios.enter.js +105 -9
- package/dist/popover/animations/ios.leave.d.ts.map +1 -1
- package/dist/popover/animations/ios.leave.js +15 -1
- package/dist/popover/callout-surface.d.ts +2 -0
- package/dist/popover/callout-surface.d.ts.map +1 -0
- package/dist/popover/callout-surface.js +44 -0
- package/dist/sheets-of-glass/index.js +2 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +0 -223
- package/docs/experimental-animation.md +3 -3
- package/docs/features.md +7 -2
- package/docs/native-ui-shell.md +9 -5
- package/docs/special-markup.md +17 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellButton.swift +7 -2
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +10 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSegment.swift +47 -15
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +42 -7
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +13 -6
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellCrossfade.swift +35 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +6 -1
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +59 -0
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +12 -1
- package/src/native/definitions.ts +4 -0
- package/src/native/runtime.ts +54 -31
- package/src/native/shared/candidate.ts +19 -0
- package/src/native/shared/crossfade.ts +49 -0
- package/src/native/shared/dom.ts +4 -1
- package/src/popover/animations/ios.enter.ts +123 -10
- package/src/popover/animations/ios.leave.ts +15 -1
- package/src/popover/callout-surface.ts +54 -0
- package/src/sheets-of-glass/index.ts +2 -2
- package/src/styles/components/ion-action-sheet.scss +35 -17
- package/src/styles/components/ion-alert.scss +51 -31
- package/src/styles/components/ion-button.scss +4 -7
- package/src/styles/components/ion-card.scss +64 -3
- package/src/styles/components/ion-fab.scss +3 -5
- package/src/styles/components/ion-list.scss +14 -1
- package/src/styles/components/ion-menu.scss +19 -7
- package/src/styles/components/ion-modal.scss +63 -14
- package/src/styles/components/ion-popover.scss +52 -9
- package/src/styles/components/ion-range.scss +49 -50
- package/src/styles/components/ion-segment.scss +15 -16
- package/src/styles/components/ion-tabs.scss +73 -13
- package/src/styles/components/ion-toast.scss +16 -25
- package/src/styles/components/ion-toolbar.scss +17 -1
- package/src/styles/default-variables.scss +4 -0
- package/src/styles/utils/_glass.scss +96 -0
- package/src/styles/utils/api.scss +46 -4
- package/src/styles/utils/dark/ion-button.scss +13 -0
- package/src/styles/utils/dark/ion-fab.scss +9 -0
- package/src/styles/utils/dark/ion-list.scss +7 -0
- package/src/styles/utils/dark/ion-segment.scss +8 -3
- package/src/styles/utils/dark/ion-tabs.scss +20 -0
- package/src/styles/utils/group-heading.scss +5 -0
- package/src/styles/utils/structured-list.scss +68 -15
- package/src/styles/utils/theme-dark.scss +23 -0
- package/src/transition/ios.transition.ts +0 -487
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const createCalloutSurface = (root, width, height, side, center) => {
|
|
2
|
+
const inset = 32;
|
|
3
|
+
const x = inset;
|
|
4
|
+
const y = inset;
|
|
5
|
+
const right = x + width;
|
|
6
|
+
const bottom = y + height;
|
|
7
|
+
const radius = Math.max(0, Math.min(34, width / 2, height / 2, (side === 'left' || side === 'right' ? height : width) / 2 - 17));
|
|
8
|
+
const point = (u, v) => {
|
|
9
|
+
switch (side) {
|
|
10
|
+
case 'top':
|
|
11
|
+
return `${x + center - 17 + u} ${y - 13 + v}`;
|
|
12
|
+
case 'right':
|
|
13
|
+
return `${right + 13 - v} ${y + center - 17 + u}`;
|
|
14
|
+
case 'bottom':
|
|
15
|
+
return `${x + center + 17 - u} ${bottom + 13 - v}`;
|
|
16
|
+
case 'left':
|
|
17
|
+
return `${x - 13 + v} ${y + center + 17 - u}`;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const arrow = `L ${point(0, 13)} C ${point(3, 13)} ${point(4.5, 12)} ${point(6.5, 10)} L ${point(14.5, 1.5)} C ${point(16, -0.5)} ${point(18, -0.5)} ${point(19.5, 1.5)} L ${point(27.5, 10)} C ${point(29.5, 12)} ${point(31, 13)} ${point(34, 13)}`;
|
|
21
|
+
const path = `M ${x + radius} ${y} ${side === 'top' ? arrow : ''}
|
|
22
|
+
L ${right - radius} ${y} A ${radius} ${radius} 0 0 1 ${right} ${y + radius} ${side === 'right' ? arrow : ''}
|
|
23
|
+
L ${right} ${bottom - radius} A ${radius} ${radius} 0 0 1 ${right - radius} ${bottom} ${side === 'bottom' ? arrow : ''}
|
|
24
|
+
L ${x + radius} ${bottom} A ${radius} ${radius} 0 0 1 ${x} ${bottom - radius} ${side === 'left' ? arrow : ''}
|
|
25
|
+
L ${x} ${y + radius} A ${radius} ${radius} 0 0 1 ${x + radius} ${y} Z`;
|
|
26
|
+
const svg = (body) => `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width + inset * 2}" height="${height + inset * 2}">${body}</svg>`)}")`;
|
|
27
|
+
const fill = svg(`<path d="${path}" fill="white"/>`);
|
|
28
|
+
const stroke = svg(`<path d="${path}" fill="none" stroke="white" stroke-width="0.5"/>`);
|
|
29
|
+
const shadow = svg(`<defs><filter id="s" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="4"/><feOffset dy="2"/><feComposite in2="SourceGraphic" operator="out"/></filter></defs><path d="${path}" fill="white" filter="url(#s)"/>`);
|
|
30
|
+
const wrapper = root.querySelector('.popover-wrapper');
|
|
31
|
+
return [
|
|
32
|
+
['callout-shadow', shadow],
|
|
33
|
+
['callout-glass', fill],
|
|
34
|
+
['callout-border', stroke],
|
|
35
|
+
].map(([part, mask]) => {
|
|
36
|
+
const element = document.createElement('div');
|
|
37
|
+
element.setAttribute('part', part);
|
|
38
|
+
element.className = 'ios-theme-callout-layer';
|
|
39
|
+
element.setAttribute('aria-hidden', 'true');
|
|
40
|
+
element.style.cssText = `position:absolute;pointer-events:none;z-index:9;width:${width + inset * 2}px;height:${height + inset * 2}px;mask-image:${mask};-webkit-mask-image:${mask};mask-repeat:no-repeat;`;
|
|
41
|
+
wrapper.append(element);
|
|
42
|
+
return element;
|
|
43
|
+
});
|
|
44
|
+
};
|
|
@@ -262,10 +262,11 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
|
|
|
262
262
|
stopTabPress();
|
|
263
263
|
moveAnimation.destroy();
|
|
264
264
|
moveAnimation = undefined;
|
|
265
|
+
const tabs = Array.from(targetElement.querySelectorAll(effectTagName)).filter((tab) => tab.offsetWidth > 0);
|
|
265
266
|
const releasing = createTabBarReleaseAnimation(effectElement, currentTouchedElement, tapElapsed !== undefined && tapElapsed < 200
|
|
266
267
|
? {
|
|
267
268
|
elapsed: tapElapsed,
|
|
268
|
-
distance: Math.abs(currentTouchedElement
|
|
269
|
+
distance: Math.abs(tabs.indexOf(currentTouchedElement) - tabs.indexOf(selectedElementBeforeGesture)),
|
|
269
270
|
}
|
|
270
271
|
: undefined);
|
|
271
272
|
currentTouchedElement.classList.remove('ion-activated');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ios.transition.d.ts","sourceRoot":"","sources":["../../src/transition/ios.transition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"ios.transition.d.ts","sourceRoot":"","sources":["../../src/transition/ios.transition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAUjD,eAAO,MAAM,MAAM,GAAI,CAAC,SAAS,OAAO,EAAE,IAAI,CAAC,KAAG,UAAU,GAAG,CAE9D,CAAC;AAyGF,eAAO,MAAM,sBAAsB,GAAI,OAAO,WAAW,EAAE,MAAM,iBAAiB,KAAG,SA4WpF,CAAC"}
|
|
@@ -99,228 +99,6 @@ const animateFixedBackButton = (root, navEl, page, entering, interactive, otherP
|
|
|
99
99
|
});
|
|
100
100
|
root.addAnimation(animation);
|
|
101
101
|
};
|
|
102
|
-
const getLargeTitle = (refEl) => {
|
|
103
|
-
const tabs = refEl.tagName === 'ION-TABS' ? refEl : refEl.querySelector('ion-tabs');
|
|
104
|
-
const query = 'ion-content ion-header:not(.header-collapse-condense-inactive) ion-title.title-large';
|
|
105
|
-
if (tabs != null) {
|
|
106
|
-
const activeTab = tabs.querySelector('ion-tab:not(.tab-hidden), .ion-page:not(.ion-page-hidden)');
|
|
107
|
-
return activeTab != null ? activeTab.querySelector(query) : null;
|
|
108
|
-
}
|
|
109
|
-
return refEl.querySelector(query);
|
|
110
|
-
};
|
|
111
|
-
const getBackButton = (refEl, backDirection) => {
|
|
112
|
-
const tabs = refEl.tagName === 'ION-TABS' ? refEl : refEl.querySelector('ion-tabs');
|
|
113
|
-
let buttonsList = [];
|
|
114
|
-
if (tabs != null) {
|
|
115
|
-
const activeTab = tabs.querySelector('ion-tab:not(.tab-hidden), .ion-page:not(.ion-page-hidden)');
|
|
116
|
-
if (activeTab != null) {
|
|
117
|
-
buttonsList = activeTab.querySelectorAll('ion-buttons');
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
buttonsList = refEl.querySelectorAll('ion-buttons');
|
|
122
|
-
}
|
|
123
|
-
for (const buttons of buttonsList) {
|
|
124
|
-
const parentHeader = buttons.closest('ion-header');
|
|
125
|
-
const activeHeader = parentHeader && !parentHeader.classList.contains('header-collapse-condense-inactive');
|
|
126
|
-
const backButton = buttons.querySelector('ion-back-button');
|
|
127
|
-
const buttonsCollapse = buttons.classList.contains('buttons-collapse');
|
|
128
|
-
const startSlot = buttons.slot === 'start' || buttons.slot === '';
|
|
129
|
-
if (backButton !== null && startSlot && ((buttonsCollapse && activeHeader && backDirection) || !buttonsCollapse)) {
|
|
130
|
-
return backButton;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
};
|
|
135
|
-
const createLargeTitleTransition = (rootAnimation, rtl, backDirection, enteringEl, leavingEl) => {
|
|
136
|
-
const enteringBackButton = getBackButton(enteringEl, backDirection);
|
|
137
|
-
const leavingLargeTitle = getLargeTitle(leavingEl);
|
|
138
|
-
const enteringLargeTitle = getLargeTitle(enteringEl);
|
|
139
|
-
const leavingBackButton = getBackButton(leavingEl, backDirection);
|
|
140
|
-
const shouldAnimationForward = enteringBackButton !== null && leavingLargeTitle !== null && !backDirection;
|
|
141
|
-
const shouldAnimationBackward = enteringLargeTitle !== null && leavingBackButton !== null && backDirection;
|
|
142
|
-
if (shouldAnimationForward) {
|
|
143
|
-
const leavingLargeTitleBox = leavingLargeTitle.getBoundingClientRect();
|
|
144
|
-
const enteringBackButtonBox = enteringBackButton.getBoundingClientRect();
|
|
145
|
-
const enteringBackButtonTextEl = shadow(enteringBackButton).querySelector('.button-text');
|
|
146
|
-
const enteringBackButtonTextBox = enteringBackButtonTextEl?.getBoundingClientRect();
|
|
147
|
-
const leavingLargeTitleTextEl = shadow(leavingLargeTitle).querySelector('.toolbar-title');
|
|
148
|
-
const leavingLargeTitleTextBox = leavingLargeTitleTextEl.getBoundingClientRect();
|
|
149
|
-
animateLargeTitle(rootAnimation, rtl, backDirection, leavingLargeTitle, leavingLargeTitleBox, leavingLargeTitleTextBox, enteringBackButtonBox, enteringBackButtonTextEl, enteringBackButtonTextBox);
|
|
150
|
-
}
|
|
151
|
-
else if (shouldAnimationBackward) {
|
|
152
|
-
const enteringLargeTitleBox = enteringLargeTitle.getBoundingClientRect();
|
|
153
|
-
const leavingBackButtonBox = leavingBackButton.getBoundingClientRect();
|
|
154
|
-
const leavingBackButtonTextEl = shadow(leavingBackButton).querySelector('.button-text');
|
|
155
|
-
const leavingBackButtonTextBox = leavingBackButtonTextEl?.getBoundingClientRect();
|
|
156
|
-
const enteringLargeTitleTextEl = shadow(enteringLargeTitle).querySelector('.toolbar-title');
|
|
157
|
-
const enteringLargeTitleTextBox = enteringLargeTitleTextEl.getBoundingClientRect();
|
|
158
|
-
animateLargeTitle(rootAnimation, rtl, backDirection, enteringLargeTitle, enteringLargeTitleBox, enteringLargeTitleTextBox, leavingBackButtonBox, leavingBackButtonTextEl, leavingBackButtonTextBox);
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
forward: shouldAnimationForward,
|
|
162
|
-
backward: shouldAnimationBackward,
|
|
163
|
-
};
|
|
164
|
-
};
|
|
165
|
-
const animateBackButton = (rootAnimation, rtl, backDirection, backButtonEl, backButtonBox, backButtonTextEl, backButtonTextBox, largeTitleEl, largeTitleTextBox) => {
|
|
166
|
-
const BACK_BUTTON_START_OFFSET = rtl ? `calc(100% - ${backButtonBox.right + 4}px)` : `${backButtonBox.left - 4}px`;
|
|
167
|
-
const TEXT_ORIGIN_X = rtl ? 'right' : 'left';
|
|
168
|
-
const ICON_ORIGIN_X = rtl ? 'left' : 'right';
|
|
169
|
-
const CONTAINER_ORIGIN_X = rtl ? 'right' : 'left';
|
|
170
|
-
let WIDTH_SCALE = 1;
|
|
171
|
-
let HEIGHT_SCALE = 1;
|
|
172
|
-
let TEXT_START_SCALE = `scale(${HEIGHT_SCALE})`;
|
|
173
|
-
const TEXT_END_SCALE = 'scale(1)';
|
|
174
|
-
if (backButtonTextEl && backButtonTextBox) {
|
|
175
|
-
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
|
|
176
|
-
WIDTH_SCALE = largeTitleTextBox.width / backButtonTextBox.width;
|
|
177
|
-
HEIGHT_SCALE = (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET) / backButtonTextBox.height;
|
|
178
|
-
TEXT_START_SCALE = doTitleAndButtonTextsMatch ? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})` : `scale(${HEIGHT_SCALE})`;
|
|
179
|
-
}
|
|
180
|
-
const backButtonIconEl = shadow(backButtonEl).querySelector('ion-icon');
|
|
181
|
-
const backButtonIconBox = backButtonIconEl.getBoundingClientRect();
|
|
182
|
-
const CONTAINER_START_TRANSLATE_X = rtl
|
|
183
|
-
? `${backButtonIconBox.width / 2 - (backButtonIconBox.right - backButtonBox.right)}px`
|
|
184
|
-
: `${backButtonBox.left - backButtonIconBox.width / 2}px`;
|
|
185
|
-
const CONTAINER_END_TRANSLATE_X = rtl ? `-${window.innerWidth - backButtonBox.right}px` : `${backButtonBox.left}px`;
|
|
186
|
-
const CONTAINER_START_TRANSLATE_Y = `${largeTitleTextBox.top}px`;
|
|
187
|
-
const CONTAINER_END_TRANSLATE_Y = `${backButtonBox.top}px`;
|
|
188
|
-
const FORWARD_CONTAINER_KEYFRAMES = [
|
|
189
|
-
{ offset: 0, transform: `translate3d(${CONTAINER_START_TRANSLATE_X}, ${CONTAINER_START_TRANSLATE_Y}, 0)` },
|
|
190
|
-
{ offset: 1, transform: `translate3d(${CONTAINER_END_TRANSLATE_X}, ${CONTAINER_END_TRANSLATE_Y}, 0)` },
|
|
191
|
-
];
|
|
192
|
-
const BACKWARD_CONTAINER_KEYFRAMES = [
|
|
193
|
-
{ offset: 0, transform: `translate3d(${CONTAINER_END_TRANSLATE_X}, ${CONTAINER_END_TRANSLATE_Y}, 0)` },
|
|
194
|
-
{ offset: 1, transform: `translate3d(${CONTAINER_START_TRANSLATE_X}, ${CONTAINER_START_TRANSLATE_Y}, 0)` },
|
|
195
|
-
];
|
|
196
|
-
const CONTAINER_KEYFRAMES = backDirection ? BACKWARD_CONTAINER_KEYFRAMES : FORWARD_CONTAINER_KEYFRAMES;
|
|
197
|
-
const FORWARD_TEXT_KEYFRAMES = [
|
|
198
|
-
{ offset: 0, opacity: 0, transform: TEXT_START_SCALE },
|
|
199
|
-
{ offset: 1, opacity: 1, transform: TEXT_END_SCALE },
|
|
200
|
-
];
|
|
201
|
-
const BACKWARD_TEXT_KEYFRAMES = [
|
|
202
|
-
{ offset: 0, opacity: 1, transform: TEXT_END_SCALE },
|
|
203
|
-
{ offset: 1, opacity: 0, transform: TEXT_START_SCALE },
|
|
204
|
-
];
|
|
205
|
-
const TEXT_KEYFRAMES = backDirection ? BACKWARD_TEXT_KEYFRAMES : FORWARD_TEXT_KEYFRAMES;
|
|
206
|
-
const FORWARD_ICON_KEYFRAMES = [
|
|
207
|
-
{ offset: 0, opacity: 0, transform: 'scale(0.6)' },
|
|
208
|
-
{ offset: 0.6, opacity: 0, transform: 'scale(0.6)' },
|
|
209
|
-
{ offset: 1, opacity: 1, transform: 'scale(1)' },
|
|
210
|
-
];
|
|
211
|
-
const BACKWARD_ICON_KEYFRAMES = [
|
|
212
|
-
{ offset: 0, opacity: 1, transform: 'scale(1)' },
|
|
213
|
-
{ offset: 0.2, opacity: 0, transform: 'scale(0.6)' },
|
|
214
|
-
{ offset: 1, opacity: 0, transform: 'scale(0.6)' },
|
|
215
|
-
];
|
|
216
|
-
const ICON_KEYFRAMES = backDirection ? BACKWARD_ICON_KEYFRAMES : FORWARD_ICON_KEYFRAMES;
|
|
217
|
-
const enteringBackButtonTextAnimation = createAnimation();
|
|
218
|
-
const enteringBackButtonIconAnimation = createAnimation();
|
|
219
|
-
const enteringBackButtonAnimation = createAnimation();
|
|
220
|
-
const clonedBackButtonEl = getClonedElement('ion-back-button');
|
|
221
|
-
const clonedBackButtonTextEl = shadow(clonedBackButtonEl).querySelector('.button-text');
|
|
222
|
-
const clonedBackButtonIconEl = shadow(clonedBackButtonEl).querySelector('ion-icon');
|
|
223
|
-
clonedBackButtonEl.text = backButtonEl.text;
|
|
224
|
-
clonedBackButtonEl.mode = backButtonEl.mode;
|
|
225
|
-
clonedBackButtonEl.icon = backButtonEl.icon;
|
|
226
|
-
clonedBackButtonEl.color = backButtonEl.color;
|
|
227
|
-
clonedBackButtonEl.disabled = backButtonEl.disabled;
|
|
228
|
-
clonedBackButtonEl.style.setProperty('display', 'block');
|
|
229
|
-
clonedBackButtonEl.style.setProperty('position', 'fixed');
|
|
230
|
-
enteringBackButtonIconAnimation.addElement(clonedBackButtonIconEl);
|
|
231
|
-
enteringBackButtonTextAnimation.addElement(clonedBackButtonTextEl);
|
|
232
|
-
enteringBackButtonAnimation.addElement(clonedBackButtonEl);
|
|
233
|
-
enteringBackButtonAnimation
|
|
234
|
-
.beforeStyles({
|
|
235
|
-
position: 'absolute',
|
|
236
|
-
top: '0px',
|
|
237
|
-
[CONTAINER_ORIGIN_X]: '0px',
|
|
238
|
-
})
|
|
239
|
-
.beforeAddWrite(() => {
|
|
240
|
-
backButtonEl.style.setProperty('display', 'none');
|
|
241
|
-
clonedBackButtonEl.style.setProperty(TEXT_ORIGIN_X, BACK_BUTTON_START_OFFSET);
|
|
242
|
-
})
|
|
243
|
-
.afterAddWrite(() => {
|
|
244
|
-
backButtonEl.style.setProperty('display', '');
|
|
245
|
-
clonedBackButtonEl.style.setProperty('display', 'none');
|
|
246
|
-
clonedBackButtonEl.style.removeProperty(TEXT_ORIGIN_X);
|
|
247
|
-
})
|
|
248
|
-
.keyframes(CONTAINER_KEYFRAMES);
|
|
249
|
-
enteringBackButtonTextAnimation
|
|
250
|
-
.beforeStyles({
|
|
251
|
-
'transform-origin': `${TEXT_ORIGIN_X} top`,
|
|
252
|
-
})
|
|
253
|
-
.keyframes(TEXT_KEYFRAMES);
|
|
254
|
-
enteringBackButtonIconAnimation
|
|
255
|
-
.beforeStyles({
|
|
256
|
-
'transform-origin': `${ICON_ORIGIN_X} center`,
|
|
257
|
-
})
|
|
258
|
-
.keyframes(ICON_KEYFRAMES);
|
|
259
|
-
rootAnimation.addAnimation([enteringBackButtonTextAnimation, enteringBackButtonIconAnimation, enteringBackButtonAnimation]);
|
|
260
|
-
};
|
|
261
|
-
const animateLargeTitle = (rootAnimation, rtl, backDirection, largeTitleEl, largeTitleBox, largeTitleTextBox, backButtonBox, backButtonTextEl, backButtonTextBox) => {
|
|
262
|
-
const ORIGIN_X = rtl ? 'right' : 'left';
|
|
263
|
-
const TITLE_START_OFFSET = rtl ? `calc(100% - ${largeTitleBox.right}px)` : `${largeTitleBox.left}px`;
|
|
264
|
-
const START_TRANSLATE_X = '0px';
|
|
265
|
-
const START_TRANSLATE_Y = `${largeTitleBox.top}px`;
|
|
266
|
-
const LARGE_TITLE_TRANSLATION_OFFSET = 8;
|
|
267
|
-
let END_TRANSLATE_X = rtl
|
|
268
|
-
? `-${window.innerWidth - backButtonBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px`
|
|
269
|
-
: `${backButtonBox.x + LARGE_TITLE_TRANSLATION_OFFSET}px`;
|
|
270
|
-
let HEIGHT_SCALE = 0.5;
|
|
271
|
-
const START_SCALE = 'scale(1)';
|
|
272
|
-
let END_SCALE = `scale(${HEIGHT_SCALE})`;
|
|
273
|
-
if (backButtonTextEl && backButtonTextBox) {
|
|
274
|
-
END_TRANSLATE_X = rtl
|
|
275
|
-
? `-${window.innerWidth - backButtonTextBox.right - LARGE_TITLE_TRANSLATION_OFFSET}px`
|
|
276
|
-
: `${backButtonTextBox.x - LARGE_TITLE_TRANSLATION_OFFSET}px`;
|
|
277
|
-
const doTitleAndButtonTextsMatch = backButtonTextEl.textContent?.trim() === largeTitleEl.textContent?.trim();
|
|
278
|
-
const WIDTH_SCALE = backButtonTextBox.width / largeTitleTextBox.width;
|
|
279
|
-
HEIGHT_SCALE = backButtonTextBox.height / (largeTitleTextBox.height - LARGE_TITLE_SIZE_OFFSET);
|
|
280
|
-
END_SCALE = doTitleAndButtonTextsMatch ? `scale(${WIDTH_SCALE}, ${HEIGHT_SCALE})` : `scale(${HEIGHT_SCALE})`;
|
|
281
|
-
}
|
|
282
|
-
const backButtonMidPoint = backButtonBox.top + backButtonBox.height / 2;
|
|
283
|
-
const titleMidPoint = (largeTitleBox.height * HEIGHT_SCALE) / 2;
|
|
284
|
-
const END_TRANSLATE_Y = `${backButtonMidPoint - titleMidPoint}px`;
|
|
285
|
-
const BACKWARDS_KEYFRAMES = [
|
|
286
|
-
{ offset: 0, opacity: 0, transform: `translate3d(${END_TRANSLATE_X}, ${END_TRANSLATE_Y}, 0) ${END_SCALE}` },
|
|
287
|
-
{ offset: 0.1, opacity: 0 },
|
|
288
|
-
{ offset: 1, opacity: 1, transform: `translate3d(${START_TRANSLATE_X}, ${START_TRANSLATE_Y}, 0) ${START_SCALE}` },
|
|
289
|
-
];
|
|
290
|
-
const FORWARDS_KEYFRAMES = [
|
|
291
|
-
{
|
|
292
|
-
offset: 0,
|
|
293
|
-
opacity: 0.99,
|
|
294
|
-
transform: `translate3d(${START_TRANSLATE_X}, ${START_TRANSLATE_Y}, 0) ${START_SCALE}`,
|
|
295
|
-
},
|
|
296
|
-
{ offset: 0.6, opacity: 0 },
|
|
297
|
-
{ offset: 1, opacity: 0, transform: `translate3d(${END_TRANSLATE_X}, ${END_TRANSLATE_Y}, 0) ${END_SCALE}` },
|
|
298
|
-
];
|
|
299
|
-
const KEYFRAMES = backDirection ? BACKWARDS_KEYFRAMES : FORWARDS_KEYFRAMES;
|
|
300
|
-
const clonedTitleEl = getClonedElement('ion-title');
|
|
301
|
-
const clonedLargeTitleAnimation = createAnimation();
|
|
302
|
-
clonedTitleEl.innerText = largeTitleEl.innerText;
|
|
303
|
-
clonedTitleEl.size = largeTitleEl.size;
|
|
304
|
-
clonedTitleEl.color = largeTitleEl.color;
|
|
305
|
-
clonedLargeTitleAnimation.addElement(clonedTitleEl);
|
|
306
|
-
clonedLargeTitleAnimation
|
|
307
|
-
.beforeStyles({
|
|
308
|
-
'transform-origin': `${ORIGIN_X} top`,
|
|
309
|
-
height: `${largeTitleBox.height}px`,
|
|
310
|
-
display: '',
|
|
311
|
-
position: 'relative',
|
|
312
|
-
[ORIGIN_X]: TITLE_START_OFFSET,
|
|
313
|
-
})
|
|
314
|
-
.beforeAddWrite(() => {
|
|
315
|
-
largeTitleEl.style.setProperty('opacity', '0');
|
|
316
|
-
})
|
|
317
|
-
.afterAddWrite(() => {
|
|
318
|
-
largeTitleEl.style.setProperty('opacity', '');
|
|
319
|
-
clonedTitleEl.style.setProperty('display', 'none');
|
|
320
|
-
})
|
|
321
|
-
.keyframes(KEYFRAMES);
|
|
322
|
-
rootAnimation.addAnimation(clonedLargeTitleAnimation);
|
|
323
|
-
};
|
|
324
102
|
export const iosTransitionAnimation = (navEl, opts) => {
|
|
325
103
|
try {
|
|
326
104
|
const EASING = 'cubic-bezier(0.32,0.72,0,1)';
|
|
@@ -609,4 +387,3 @@ export const iosTransitionAnimation = (navEl, opts) => {
|
|
|
609
387
|
throw err;
|
|
610
388
|
}
|
|
611
389
|
};
|
|
612
|
-
const LARGE_TITLE_SIZE_OFFSET = 10;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
title:
|
|
2
|
+
title: Animation
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# Animation
|
|
6
6
|
|
|
7
|
-
These gesture and animation helpers are
|
|
7
|
+
These gesture and animation helpers are production-ready and optional. The theme works without them.
|
|
8
8
|
|
|
9
9
|
## Sheet of Glass with `ion-tab-button` / `ion-segment-button`
|
|
10
10
|
|
package/docs/features.md
CHANGED
|
@@ -11,6 +11,8 @@ Customize the theme with CSS variables and Sass mixins, or adopt it one componen
|
|
|
11
11
|
To customize the library's default styles to match your design, several CSS variables are provided. See this file for details:
|
|
12
12
|
[Default variables](../src/styles/default-variables.scss)
|
|
13
13
|
|
|
14
|
+
For menus, `--ios-theme-menu-background-rgb` sets the surface RGB channels (light: `225, 230, 240`; dark: `26, 31, 34`). `--ios-theme-menu-background-opacity` controls opacity and defaults to `0.96`. Both are public customization variables and can be set on `ion-menu`.
|
|
15
|
+
|
|
14
16
|
## Liquid Glass mixin
|
|
15
17
|
|
|
16
18
|
Import the SCSS files from the main package to use the liquid glass mixin.
|
|
@@ -23,9 +25,9 @@ ion-textarea label.textarea-wrapper {
|
|
|
23
25
|
}
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
## Native UI Shell
|
|
28
|
+
## Native UI Shell (Experimental)
|
|
27
29
|
|
|
28
|
-
Capacitor iOS apps can use the optional [Native UI Shell](native-ui-shell.md) to render supported fixed Ionic controls with UIKit and system Liquid Glass. Content and application logic stay in the WebView, with Ionic owning routing and page transitions. The guide explains the hybrid approach's origins in Basecamp and Capacitor, setup, supported controls and Web fallback behavior.
|
|
30
|
+
Capacitor iOS apps can use the optional, experimental [Native UI Shell](native-ui-shell.md) to render supported fixed Ionic controls with UIKit and system Liquid Glass. Content and application logic stay in the WebView, with Ionic owning routing and page transitions. The guide explains the hybrid approach's origins in Basecamp and Capacitor, setup, supported controls and Web fallback behavior.
|
|
29
31
|
|
|
30
32
|
## Selective component imports
|
|
31
33
|
|
|
@@ -51,6 +53,7 @@ Always:
|
|
|
51
53
|
:root {
|
|
52
54
|
@include theme-dark.default-variables;
|
|
53
55
|
}
|
|
56
|
+
@include theme-dark.ion-list;
|
|
54
57
|
@include theme-dark.ion-button;
|
|
55
58
|
@include theme-dark.ion-fab;
|
|
56
59
|
@include theme-dark.ion-tabs;
|
|
@@ -66,6 +69,7 @@ System:
|
|
|
66
69
|
:root {
|
|
67
70
|
@include theme-dark.default-variables;
|
|
68
71
|
}
|
|
72
|
+
@include theme-dark.ion-list;
|
|
69
73
|
@include theme-dark.ion-button;
|
|
70
74
|
@include theme-dark.ion-fab;
|
|
71
75
|
@include theme-dark.ion-tabs;
|
|
@@ -80,6 +84,7 @@ Class:
|
|
|
80
84
|
|
|
81
85
|
.ion-palette-dark {
|
|
82
86
|
@include theme-dark.default-variables;
|
|
87
|
+
@include theme-dark.ion-list;
|
|
83
88
|
@include theme-dark.ion-button;
|
|
84
89
|
@include theme-dark.ion-fab;
|
|
85
90
|
@include theme-dark.ion-tabs;
|
package/docs/native-ui-shell.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Native UI Shell
|
|
2
|
+
title: Native UI Shell (Experimental)
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
# Native UI Shell
|
|
5
|
+
# Native UI Shell (Experimental)
|
|
6
|
+
|
|
7
|
+
Native UI Shell is experimental. Its API and supported controls may change.
|
|
6
8
|
|
|
7
9
|
Native UI Shell gives an Ionic app native navigation and action controls around its Web content. The optional Capacitor iOS plugin renders supported fixed Ionic controls with UIKit and the system's Liquid Glass material. Page content, scrolling, application state and routing remain in Ionic's WebView.
|
|
8
10
|
|
|
@@ -54,7 +56,7 @@ If a child inside a shared native surface opts out, the entire surface stays on
|
|
|
54
56
|
|
|
55
57
|
Placement is required even when the appearance is glass. Buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility.
|
|
56
58
|
|
|
57
|
-
Native tabs accept equal-width items with Ionic's default `layout="icon-top"`.
|
|
59
|
+
Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip.
|
|
58
60
|
|
|
59
61
|
Standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles.
|
|
60
62
|
|
|
@@ -62,13 +64,13 @@ Native glass samples the Web content actually drawn behind it. Existing toolbar
|
|
|
62
64
|
|
|
63
65
|
While a Web input owns the software keyboard, ordinary controls return to Web rendering and are re-evaluated when it closes. This also covers iPad layouts where the keyboard does not move the visual viewport. A native search field keeps its own native keyboard and search surface.
|
|
64
66
|
|
|
65
|
-
When an existing control becomes unsupported, its Web source is painted before its native cover is removed. This avoids a blank handoff, but Web/UIKit updates are not atomic
|
|
67
|
+
When an existing control becomes unsupported, its Web source is painted before its native cover is removed. This avoids a blank handoff, but Web/UIKit updates are not atomic and can briefly overlap. This boundary behavior differs from ordinary page navigation, where unchanged shared native tabs are retained.
|
|
66
68
|
|
|
67
69
|
## State and events
|
|
68
70
|
|
|
69
71
|
Menu buttons use Ionic's resolved default/configured icon or a supported slot icon/label. Native activation clicks the original `ion-menu-button`, preserving `menu` targeting. Non-default menu button types (`submit` / `reset`) remain on the Web. `disabled`, `autoHide`, menu availability and split-pane visibility follow the actual DOM. Opening a menu restores Web controls and retires their native covers; closing it reprojects eligible controls.
|
|
70
72
|
|
|
71
|
-
The DOM owns labels, SVG content, placement, selected values and application behavior. The placement reference is `ion-tab-bar` itself. The native tab content
|
|
73
|
+
The DOM owns labels, SVG content, placement, selected values and application behavior. The placement reference is `ion-tab-bar` itself. The native tab content uses that rectangle as its sizing proposal and placement anchor, accounting for UITabBar's larger outer frame: `tab-bar-position-start`, `tab-bar-position-center`, and `tab-bar-position-end` control the horizontal anchor (including RTL), while `slot="bottom"` retains the bottom edge and `slot="top"` retains the top edge. Class changes are reconciled automatically. UIKit owns internal margins and platter sizing, while the supported stacked layout and label typography follow the Web. UIKit can cap the content width even with fill positioning. On viewports at least 768px wide, the Web theme caps standard two-, three-, four- and five-item bars near the iPad stacked platter measurements (188, 274, 336 and 414pt). Smaller viewports retain the phone sizing. The standard bar is 62pt high, with a 54pt selection and a 4pt inset. Adjacent buttons overlap like UIKit’s controls; icon and label alignment is checked against simulator screenshots. These are default appearance targets, not a guarantee for custom fonts, icons or labels; UIKit still determines its intrinsic width. A different native width or height does not reject projection. Badge and title updates retain native item identities and remeasure the platter at the same placement anchor. The plugin does not stretch icons or modify UIKit's internal controls to force CSS item widths. The plugin measures the view subtree containing the native tab controls without private class names or fixed inset corrections; an unrecognized layout restores Web rendering. The runtime watches structural changes, affected shadow roots, sizing, page lifecycle events and overlays. Ancestor `display: none`, `hidden`, theme classes, component removal and disabled changes are reconciled automatically. Native touch events are checked against the current DOM and revision before clicking the original Ionic element.
|
|
72
74
|
|
|
73
75
|
For forms, retain `ion-button type="submit"` and the form's existing submit handler. An external form is still passed as `[form]="formRef"`. The plugin does not call `form.submit()`, add a second submit path or change Angular form ownership. Segment values retain their original type because the original `ion-segment-button` is clicked; programmatic value changes do not emit a synthetic `ionChange`.
|
|
74
76
|
|
|
@@ -132,3 +134,5 @@ On iOS, [`Components`](../ios/Sources/IonicNativeUIShellPlugin/Components) owns
|
|
|
132
134
|
|
|
133
135
|
See the [demo and verification guide](../demo/native-ui-shell.md) for browser tests, Simulator tests and an independent SPM consumer built from the npm package.
|
|
134
136
|
|
|
137
|
+
|
|
138
|
+
Search controllers retain their UIKit-managed transition and are excluded from the ordinary control acquisition crossfade.
|
package/docs/special-markup.md
CHANGED
|
@@ -17,6 +17,23 @@ Solid submit buttons use the Ionic color's contrast value for their foreground.
|
|
|
17
17
|
|
|
18
18
|
Use `.button-submit` when the button needs the same treatment but cannot use `type="submit"`.
|
|
19
19
|
|
|
20
|
+
## Preferred overlay actions
|
|
21
|
+
|
|
22
|
+
For iOS alerts and action sheets, set `role: 'preferred'` on a button to give it a filled `--ion-color-primary` background and `--ion-color-primary-contrast` text and icons. While pressed, the background uses `--ion-color-primary-shade`. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as `preferred`.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
buttons: [
|
|
26
|
+
{ text: 'Cancel', role: 'cancel' },
|
|
27
|
+
{ text: 'Continue', role: 'preferred' },
|
|
28
|
+
];
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Buttons with no role or `default` keep the normal text color. `cancel` retains Ionic's cancellation behavior, `selected` remains a selection state, and `destructive` uses `--ios-theme-destructive-color`. An existing `confirm` role is not treated as preferred. Use `preferred` for the recommended action, not simply any action that confirms a choice.
|
|
32
|
+
|
|
33
|
+
## Floating iPad sheets
|
|
34
|
+
|
|
35
|
+
Set `expandToScroll: false` on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default `expandToScroll: true`, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.
|
|
36
|
+
|
|
20
37
|
## Tab bar position
|
|
21
38
|
|
|
22
39
|
Add one of `tab-bar-position-start`, `tab-bar-position-center`, or `tab-bar-position-end` to an iOS `ion-tab-bar` to position the whole bar within its safe area. These classes work with both `slot="top"` and `slot="bottom"` and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.
|
|
@@ -18,8 +18,9 @@ enum ShellButton {
|
|
|
18
18
|
var configuration: UIButton.Configuration = glass ? .glass() : .plain()
|
|
19
19
|
configuration.title = item.label
|
|
20
20
|
configuration.image = rendering.image(item)
|
|
21
|
-
configuration.imagePadding = 4
|
|
22
|
-
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading:
|
|
21
|
+
configuration.imagePadding = item.imagePadding ?? 4
|
|
22
|
+
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: item.contentInsetLeading ?? 4,
|
|
23
|
+
bottom: 0, trailing: item.contentInsetTrailing ?? 4)
|
|
23
24
|
configuration.imagePlacement = item.iconPosition == .trailing ? .trailing : .leading
|
|
24
25
|
configuration.baseForegroundColor = rendering.color(item.color)
|
|
25
26
|
configuration.titleLineBreakMode = .byTruncatingTail
|
|
@@ -33,6 +34,10 @@ enum ShellButton {
|
|
|
33
34
|
let button = existing ?? UIButton(configuration: configuration)
|
|
34
35
|
if existing != nil { button.configuration = configuration }
|
|
35
36
|
button.titleLabel?.numberOfLines = 1
|
|
37
|
+
// Web and UIKit can differ by a fraction of a point in text measurement.
|
|
38
|
+
// Absorb that rounding without widening the projected button.
|
|
39
|
+
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
|
40
|
+
button.titleLabel?.minimumScaleFactor = 0.99
|
|
36
41
|
button.isEnabled = !item.disabled
|
|
37
42
|
button.accessibilityLabel = item.accessibilityLabel
|
|
38
43
|
button.accessibilityIdentifier = item.id
|
|
@@ -58,6 +58,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
58
58
|
private var editingSequence = 0
|
|
59
59
|
private var valueVersion = -1
|
|
60
60
|
private var lastLayout = ""
|
|
61
|
+
private var layoutItems: [ShellItemContent] = []
|
|
61
62
|
var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
|
|
62
63
|
var activate: ((String) -> Void)?
|
|
63
64
|
var changed: ((String, ShellSearchPhase, String, Bool, Int) -> Int)?
|
|
@@ -70,6 +71,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
70
71
|
surface.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
71
72
|
delegate = self
|
|
72
73
|
mode = .tabBar
|
|
74
|
+
ShellTabBar.configureLayout(tabBar)
|
|
73
75
|
search.obscuresBackgroundDuringPresentation = false
|
|
74
76
|
search.hidesNavigationBarDuringPresentation = false
|
|
75
77
|
search.searchBar.delegate = self
|
|
@@ -122,6 +124,13 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
122
124
|
// advance native editing beyond the last input accepted by Ionic.
|
|
123
125
|
if !active { closing = true }
|
|
124
126
|
let items = snapshot.items
|
|
127
|
+
let content = items.map(\.content)
|
|
128
|
+
if layoutItems != content {
|
|
129
|
+
layoutItems = content
|
|
130
|
+
// Defer fitting while search is active, but keep the resting layout
|
|
131
|
+
// invalid until new labels, typography, icons and badges are measured.
|
|
132
|
+
lastLayout = ""
|
|
133
|
+
}
|
|
125
134
|
let ids = items.map(\.id)
|
|
126
135
|
for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) }
|
|
127
136
|
for item in items {
|
|
@@ -148,6 +157,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
|
|
|
148
157
|
for item in items {
|
|
149
158
|
if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
|
|
150
159
|
nativeItem.accessibilityLabel = item.content.accessibilityLabel
|
|
160
|
+
ShellTabBar.applyTypography(item.content, to: nativeItem)
|
|
151
161
|
ShellTabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
|
|
152
162
|
}
|
|
153
163
|
}
|
|
@@ -8,6 +8,7 @@ private final class ShellSegmentElement: UIAccessibilityElement {
|
|
|
8
8
|
final class ShellSegment: UISegmentedControl {
|
|
9
9
|
static let kind = ShellComponent.segment
|
|
10
10
|
var labels: [String] = []
|
|
11
|
+
private var items: [ShellItem] = []
|
|
11
12
|
override func layoutSubviews() {
|
|
12
13
|
super.layoutSubviews()
|
|
13
14
|
let total = (0..<numberOfSegments).reduce(CGFloat.zero) { $0 + widthForSegment(at: $1) }
|
|
@@ -35,25 +36,56 @@ final class ShellSegment: UISegmentedControl {
|
|
|
35
36
|
|
|
36
37
|
@available(iOS 26.0, *)
|
|
37
38
|
static func make(_ node: ShellControl, scale: CGFloat, rendering: ShellRendering, activate: @escaping (String) -> Void) -> UISegmentedControl? {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
let rtl = node.rtl
|
|
41
|
-
let control = ShellSegment(items: items.map { rendering.image($0.content) as Any? ?? $0.content.label })
|
|
42
|
-
control.labels = items.map { $0.content.accessibilityLabel }
|
|
39
|
+
guard !node.items.isEmpty else { return nil }
|
|
40
|
+
let control = ShellSegment(items: [])
|
|
43
41
|
control.isAccessibilityElement = false
|
|
44
|
-
control.setTitleTextAttributes([.font: UIFont.systemFont(ofSize: firstItem.content.fontSize, weight: .medium)], for: .normal)
|
|
45
|
-
control.semanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
|
|
46
42
|
control.apportionsSegmentWidthsByContent = false
|
|
47
|
-
|
|
48
|
-
control.setEnabled(!item.content.disabled, forSegmentAt: index)
|
|
49
|
-
control.setWidth(item.frame.width * scale, forSegmentAt: index)
|
|
50
|
-
if item.content.selected { control.selectedSegmentIndex = index }
|
|
51
|
-
}
|
|
52
|
-
control.accessibilityIdentifier = node.id
|
|
43
|
+
control.update(node, scale: scale, rendering: rendering)
|
|
53
44
|
control.addAction(UIAction { [weak control] _ in
|
|
54
|
-
guard let control, items.indices.contains(control.selectedSegmentIndex) else { return }
|
|
55
|
-
activate(items[control.selectedSegmentIndex].id)
|
|
45
|
+
guard let control, control.items.indices.contains(control.selectedSegmentIndex) else { return }
|
|
46
|
+
activate(control.items[control.selectedSegmentIndex].id)
|
|
56
47
|
}, for: .valueChanged)
|
|
57
48
|
return control
|
|
58
49
|
}
|
|
50
|
+
|
|
51
|
+
@available(iOS 26.0, *)
|
|
52
|
+
func update(_ node: ShellControl, scale: CGFloat, rendering: ShellRendering) {
|
|
53
|
+
let previous = items
|
|
54
|
+
let rebuilt = previous.map(\.id) != node.items.map(\.id)
|
|
55
|
+
if rebuilt {
|
|
56
|
+
removeAllSegments()
|
|
57
|
+
for index in node.items.indices { insertSegment(withTitle: "", at: index, animated: false) }
|
|
58
|
+
}
|
|
59
|
+
if let first = node.items.first, previous.first?.content.fontSize != first.content.fontSize {
|
|
60
|
+
setTitleTextAttributes([.font: UIFont.systemFont(ofSize: first.content.fontSize, weight: .medium)], for: .normal)
|
|
61
|
+
}
|
|
62
|
+
let direction: UISemanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
63
|
+
if semanticContentAttribute != direction { semanticContentAttribute = direction }
|
|
64
|
+
// Ionic button frames exclude their margins. Distribute the full track
|
|
65
|
+
// width so UIKit's fixed segment widths do not shrink the projected track.
|
|
66
|
+
let totalItemWidth = node.items.reduce(0.0) { $0 + $1.frame.width }
|
|
67
|
+
let widthScale = totalItemWidth > 0 ? node.frame.width / totalItemWidth * scale : scale
|
|
68
|
+
for (index, item) in node.items.enumerated() {
|
|
69
|
+
let old = rebuilt ? nil : previous[index].content
|
|
70
|
+
let content = item.content
|
|
71
|
+
if old?.label != content.label || old?.icon != content.icon ||
|
|
72
|
+
old?.iconWidth != content.iconWidth || old?.iconHeight != content.iconHeight ||
|
|
73
|
+
old?.iconTemplate != content.iconTemplate || old?.color != content.color {
|
|
74
|
+
let image = rendering.image(content)
|
|
75
|
+
setImage(image, forSegmentAt: index)
|
|
76
|
+
setTitle(image == nil ? content.label : nil, forSegmentAt: index)
|
|
77
|
+
}
|
|
78
|
+
if isEnabledForSegment(at: index) == content.disabled { setEnabled(!content.disabled, forSegmentAt: index) }
|
|
79
|
+
let width = item.frame.width * widthScale
|
|
80
|
+
if widthForSegment(at: index) != width { setWidth(width, forSegmentAt: index) }
|
|
81
|
+
}
|
|
82
|
+
items = node.items
|
|
83
|
+
labels = items.map { $0.content.accessibilityLabel }
|
|
84
|
+
accessibilityIdentifier = node.id
|
|
85
|
+
let selected = items.firstIndex { $0.content.selected } ?? UISegmentedControl.noSegment
|
|
86
|
+
// A native tap has already selected this index. Reassigning it can interrupt
|
|
87
|
+
// UIKit's in-flight lens animation when the Web selection echoes back.
|
|
88
|
+
if selectedSegmentIndex != selected { selectedSegmentIndex = selected }
|
|
89
|
+
setNeedsLayout()
|
|
90
|
+
}
|
|
59
91
|
}
|