Skip to content

Commit 850bb54

Browse files
shai-almogclaude
andauthored
iOS 27 tab bar: measured Liquid Glass motion, vibrancy, lens optics and native geometry (#5906)
* iOS 27 tab bar: measured Liquid Glass motion, vibrancy, lens optics and native geometry The iOS 27 Tabs (tabsMorphPreset ios27) now reproduces UITabBarController from measurements taken with an instrumented native probe (motion-probe) rather than by eye: - TabGlassMotion / TabGlassGesture: the selection motion as measured 60 Hz tables, plus held presses, a press on the selected tab, drag scrubbing with release to the tab under the finger, and the release wobble. - Graphics.colorMatrixRegion (vibrant tab content, the selection platter) and Graphics.glassLensRegion (the lifted lens's refraction, outline, rim and shadow), with core reference implementations, JavaSE, iOS/macOS Metal and (colorMatrixRegion) JavaScript port support. - Native bar geometry in points and the native label face. - iOS Metal: the bar glass runs on the GPU, mutable-image drawing reuses its stencil, the tab motion is no longer painted twice per frame, and the lens scratch texture is no longer leaked. - Tooling: tabmotion.py regenerates every table; record-ios-tabs-side-by-side.sh records native and Codename One side by side under the same real touches (docs/videos). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix CI: CLDC11 has no Math.exp, prose rule, close files in tabmotion.py GlassLensBlend uses MathUtil.exp (core compiles against CLDC11 in the Ant build). tabmotion.py opens every file through a context manager; all regenerated tables and fixtures are byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * VibrancyMatrix: closed forms replace the fitted light-alpha and grey tables The light alpha is 1 - e - 0.3 (0.5 - e) sum(1-t) / |1-t|^2, the exact value that maps a white backdrop to the tint; the grey ramp is 5/16 / q and (19/16 g - 1/4) / q with q = 1 + 0.05 g (1 - g). Every matrix the probe logged matches to 5e-6 (the table reproduced them to 0.0046). tabmotion.py vibrancy now checks captures against the forms instead of fitting tables, and the test asserts native matrix entries to 2e-5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tabs: the platter colour matrix is 1.2 I - k * luminance, not 24 literals UIKit's platter colorMatrix is a 1.2 gain less a share of the Rec. 709 luminance (0.33 dark, 0.07 light) plus an offset; every read-back entry matches within 1.4e-4, as close as UIKit's own rows agree. A test pins the formula to the native readback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * TabGlassMotion: the travel, lift, platter and glow are springs, not tables On the vsync clock (every captured frame renders at exactly start + n/60 s; the logged times carry main-thread jitter) seven of the fourteen tables are UIKit springs, written in closed form with DampedSpring: - travel: duration 0.4 s, bounce 0.15, ended past the overshoot once less than 0.195 pt remains - lift: critically damped 0.25 s rise and fall (fall ends 13.5 frames in) - platter: critically damped 0.4 s from 1 - rise and its velocity at the release (ends 28.5 frames in) - glow: layer critically damped 0.1 s to 0.8445, retargeted to 0 on a 0.5 s spring at the tap's touch-up; mask = 0.33675 * layer; each hidden under 0.005; scale 1 + 3 * the 0.5 s spring The deformation (STRETCH_*, LEAD_*) and the whole-bar pulse keep their tables: no closed form matched the capture as well. tabmotion.py check now holds the closed forms to the capture and fails beyond tolerance; java only splices the kept tables; fixture is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * TabGlassGesture: the settle after a scrub is the tap's travel spring The settle is TabGlassMotion's travel spring (POSITION_OMEGA, POSITION_ZETA, now package-private so both share one definition) started from the position and velocity the follow spring leaves the lens with when the release reaches it, one touch lag after the finger lifts. It replaces the SETTLE_V/SETTLE_E tables and the position-curve correction: scrub centre RMS 0.517 -> 0.346 pt, worst 7.47 -> 3.44 pt; lift worst 0.42 -> 0.14. Fitted to the releases alone the spring comes out at 15.5 rad/s, zeta 0.847, against 15.708 / 0.85. The release wobble and the scrub deformation kernels stay tables; a note above the kernels records the structure a closed form would have to meet. The bar press spring uses DampedSpring. tabmotion.py gesture splices the wobble and deformation tables as before and replays the settle over the episodes gesture-fixture exports (--settle), failing beyond tolerance. The fixture and the splice are byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * TabGlassMotion: drop the blank lines the removed tables left behind tabmotion.py java regenerates the remaining tables byte for byte again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Reseed the 7 TabsGlassMotion frames the spring curves moved Frames 3, 5, 7 and 17 (lift and glow onset, lift release) drifted 0.5-1.3% of pixels: the lens outline moves by a sub-pixel and the vibrant edge label recolours with the glow. The other 29 frames and every fidelity row are unchanged; strict verification passes on the iOS 27 simulator. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tabs glass: fix the review findings on ports, placement, RTL and hit testing - iOS: colour matrix and glass lens need the Metal renderer; GL and watchOS builds report no support so Tabs paints its regular content. - The glass motion applies to TOP and BOTTOM tabs only; LEFT and RIGHT keep the regular rendering. - An empty glass bar paints its glass without resolving a tab. - Right to left, the first glass tab sits at the right end. - The glass tabs overlap (each is as wide as its resting lens), so taps and scrub releases both pick the tab whose centre is nearest. - The pointer listeners stay registered with swiping off; they also drive the lens and skip the swipe themselves. - Metal (iOS and Mac): a colour-matrix region that runs off a screen edge keeps its texture coordinates and mask anchored to the full region. - JavaScript: colorMatrixRegion ignores the user transform, as on the other ports (Tabs scales its regions itself). Each new test was checked to fail with its fix reverted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Metal restarts keep clip and transform; vibrant tabs keep their own tint - A screen effect (blur, glass, lens, colour matrix) ends the Metal encoder mid-frame and opens another. That reset the transform to identity and dropped the rectangular and polygon clips the Java side had already sent, so the effect and everything after it drew unclipped. CN1Metalcompat now carries them across the restart (the polygon is re-stamped, as each pass starts with a cleared stencil) until presentFramebuffer ends the frame; clips set while drawing into a mutable image are not the screen's. - The vibrant tab content groups the tabs by their own tint in each state, one mask and matrix per group, so a tab with its own colour or a disabled tab keeps it. The usual single tint is still two masks. - Three TabsGlassMotion frames reseeded: with the transform and clip now kept, the lens rim and the vibrant edge label move by a few edge pixels. - The watchOS Core Graphics build is the only one without these ops. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Glass tab titles never scroll; reseed the TabsGlassMotion frames The first tab could gain focus while it was briefly narrower than its title (before the glass layout ran), which started a Label ticker that never stopped: the title showed as a drifting 'ed', and every TabsGlassMotion frame differed from run to run by 0.15-0.4% of pixels -- the source of the intermittent iOS 27 frame-golden failures. UITabBar never scrolls a title, so the glass layout stops and disables the ticker on its tabs. All 36 frames reseeded with 'Featured' whole and centred; two runs of the same build now capture them pixel-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tabs: the iOS 26 drop may bulge past the bar in flight Mid-flight the iOS 26 selection drop is taller than the bar and bulges past its edges, as UIKit's does, but tabsContainer's paint is clipped to the bar. On Metal the bulge only showed because an encoder restart dropped the clip; with the clip now carried across restarts it was cut at the bar edge. The drop's lens now widens the clip to the whole Tabs while in flight -- the morph repaints the whole Tabs every frame then -- and leaves it alone at rest, when the drop is inside the bar. The test implementation records lensRegion calls with their clip, and a test holds the morph mid-flight and checks the clip admits the bulge, up to the Tabs and no further. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * TabGlassGesture: test the finger state, not a float sentinel at() tested rel == Float.MAX_VALUE. ParparVM writes that constant as the double 3.4028235E38, which a C float never equals, so the test was always false on iOS and Xcode 27 reports it (-Wliteral-range), failing the native warning census of build-ios-metal-27. releaseS answers the sentinel exactly when the finger is still down, so test upS < 0 instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tabs glass: presses under overlays, preset switches, style bookkeeping; JavaSE clip - A press only drives the glass lens when the component that receives it is the tab bar or one of its tabs, not an overlay (side menu, layered-pane sheet) covering the bar. - A theme switch between the ios26 and ios27 presets reinstalls the tab layout when the glass motion flips, so neither renderer runs over the other's layout. - Button's forced glass styles keep Component's isUnselectedStyle bookkeeping, so Label no longer measures a selected title with the width it cached for the unselected font. - JavaSE: lensRegion, glassLensRegion and colorMatrixRegion write back only inside the graphics clip (mapped into buffer pixels), as the Metal and JavaScript paths do; a raw setRGB bypassed it. Each new test was checked to fail with its fix reverted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * motion-probe: keep only the tint lists the fixture needs; README matches the model The six other tint lists (adapt, cvw, grid17, lowc, slices, sweep3) densified the samples the old fitted light-alpha table needed. VibrancyMatrix is closed form now, and sweep/sweep2 -- which native-vibrancy.csv is captured from -- check it just as well (1382 tints per appearance, worst entry 5e-6). The README described tables that are now springs and formulas, the old vibrancy/gesture command lines, and a wobble onset independent of the gesture; it now states the closed forms, the current commands, and the measured onset per gesture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Tabs glass: grow the bar for tab content taller than native The floating bar was fixed at UITabBar's 62 pt, so a tab with larger icons than the native ones had its label clipped at the bottom of the pill (the TabsTheme screenshots on build-ios-metal-27). The bar is now 62 pt or the tallest tab's preferred height plus the insets, whichever is larger: native content keeps the native geometry exactly (the iOS 27 fidelity suite is unchanged), larger content fits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Reseed the iOS 27 TabsTheme screenshots The iOS 27 tab bar now renders natively (floating glass pill sized to its tabs, vibrant content, the selection lens), and grows past UITabBar's 62 pt for this screen's larger-than-native icons so the labels are no longer clipped. Captured by build-ios-metal-27 on c60d117 and checked by eye: every label sits whole inside the pill, light and dark. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6447611 commit 850bb54

133 files changed

Lines changed: 16998 additions & 132 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4100,6 +4100,91 @@ public boolean glassRegion(Object graphics, int x, int y, int width, int height,
41004100
return glassRegion(graphics, x, y, width, height, radius, cornerRadius, sat, scale, offset, refract, specular);
41014101
}
41024102

4103+
/// True when `#colorMatrixRegion` works on this graphics context. Ports override.
4104+
///
4105+
/// #### Parameters
4106+
///
4107+
/// - `graphics`: the native graphics
4108+
///
4109+
/// #### Returns
4110+
///
4111+
/// false by default
4112+
public boolean isColorMatrixRegionSupported(Object graphics) {
4113+
return false;
4114+
}
4115+
4116+
/// Recolours the painted region through a colour matrix; see
4117+
/// `com.codename1.ui.Graphics#colorMatrixRegion`. Ports override; the default does nothing.
4118+
///
4119+
/// #### Parameters
4120+
///
4121+
/// - `graphics`: the native graphics
4122+
///
4123+
/// - `x`: region x
4124+
///
4125+
/// - `y`: region y
4126+
///
4127+
/// - `width`: region width
4128+
///
4129+
/// - `height`: region height
4130+
///
4131+
/// - `matrix`: 12-float colour matrix
4132+
///
4133+
/// - `mask`: optional coverage mask
4134+
///
4135+
/// - `cornerRadius`: corner radius, negative for a capsule
4136+
///
4137+
/// - `amount`: strength 0..1
4138+
///
4139+
/// #### Returns
4140+
///
4141+
/// false when unsupported
4142+
public boolean colorMatrixRegion(Object graphics, int x, int y, int width, int height, float[] matrix,
4143+
Image mask, float cornerRadius, float amount) {
4144+
return false;
4145+
}
4146+
4147+
/// True when `glassLensRegion` works on this graphics context.
4148+
///
4149+
/// #### Parameters
4150+
///
4151+
/// - `graphics`: the graphics context
4152+
///
4153+
/// #### Returns
4154+
///
4155+
/// false by default
4156+
public boolean isGlassLensRegionSupported(Object graphics) {
4157+
return false;
4158+
}
4159+
4160+
/// See Graphics.glassLensRegion; the default does nothing.
4161+
///
4162+
/// #### Parameters
4163+
///
4164+
/// - `graphics`: the graphics context
4165+
///
4166+
/// - `x`: lens left edge (translated)
4167+
///
4168+
/// - `y`: lens top edge (translated)
4169+
///
4170+
/// - `width`: lens width
4171+
///
4172+
/// - `height`: lens height
4173+
///
4174+
/// - `cornerRadius`: corner radius, negative for a capsule
4175+
///
4176+
/// - `optics`: the GlassLensBlend parameters
4177+
///
4178+
/// - `amount`: overall strength 0..1
4179+
///
4180+
/// #### Returns
4181+
///
4182+
/// false when unsupported
4183+
public boolean glassLensRegion(Object graphics, int x, int y, int width, int height, float cornerRadius,
4184+
float[] optics, float amount) {
4185+
return false;
4186+
}
4187+
41034188
/// In-place iOS 26 selection-drop LENS (magnify + chromatic aberration +
41044189
/// dark-&gt;accent tint over the painted content). Default unsupported; the iOS
41054190
/// port overrides it. Returns false so callers can fall back (e.g. to a tint).

‎CodenameOne/src/com/codename1/ui/Button.java‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ public class Button extends Label implements ReleasableComponent, ActionSource<A
9090
private Image disabledIcon;
9191
private Command cmd;
9292
private boolean toggle;
93+
/// Paint-time override used by Tabs for the Liquid Glass selection: the tab bar
94+
/// paints every tab twice, once as unselected content and once as the accent
95+
/// copy seen through the moving lens, regardless of which tab is selected.
96+
/// GLASS_PAINT_NONE outside that paint; never set it anywhere else.
97+
int glassPaintState = GLASS_PAINT_NONE;
98+
static final int GLASS_PAINT_NONE = 0;
99+
static final int GLASS_PAINT_DEFAULT = 1;
100+
static final int GLASS_PAINT_SELECTED = 2;
93101
private int releaseRadius;
94102
private boolean autoRelease;
95103
/// Duration in millis of the iOS-style release dim-out (0 disables it). Read
@@ -664,7 +672,10 @@ public Image getIconFromState() {
664672
if (!isEnabled() && getDisabledIcon() != null) {
665673
return getDisabledIcon();
666674
}
667-
if (isToggle() && isSelected()) {
675+
if (glassPaintState == GLASS_PAINT_DEFAULT) {
676+
return icon;
677+
}
678+
if (glassPaintState == GLASS_PAINT_SELECTED || (isToggle() && isSelected())) {
668679
icon = rolloverPressedIcon;
669680
if (icon == null) {
670681
icon = getPressedIcon();
@@ -946,8 +957,30 @@ protected Border getBorder() {
946957
return getStyle().getBorder();
947958
}
948959

960+
/// {@inheritDoc}
961+
///
962+
/// While Tabs paints its Liquid Glass passes the forced state wins over focus
963+
/// and selection: a focused tab would otherwise answer its selected style and
964+
/// keep the accent colour outside the lens.
965+
@Override
966+
public Style getStyle() {
967+
if (glassPaintState != GLASS_PAINT_NONE && isEnabled()) {
968+
boolean selected = glassPaintState == GLASS_PAINT_SELECTED;
969+
Style forced = selected ? getPressedStyle() : getUnselectedStyle();
970+
// Keep Component.getStyle()'s bookkeeping: Label reuses a cached string
971+
// width only while the unselected style is the one in force, and a
972+
// selected style may carry a different font.
973+
isUnselectedStyle = !selected;
974+
return forced;
975+
}
976+
return super.getStyle();
977+
}
978+
949979
@Override
950980
boolean isPressedStyle() {
981+
if (glassPaintState != GLASS_PAINT_NONE) {
982+
return glassPaintState == GLASS_PAINT_SELECTED;
983+
}
951984
// if a toggle button has focus we should draw the selected state not the pressed state
952985
// however if shouldRenderSelection is false the selected state won't be painted so
953986
// we should draw the pressed state

‎CodenameOne/src/com/codename1/ui/Component.java‎

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3078,29 +3078,39 @@ private void paintInternalImpl(Graphics g, boolean paintIntersects) {
30783078
}
30793079
}
30803080

3081-
void internalPaintImpl(Graphics g, boolean paintIntersects) {
3082-
g.clipRect(getX(), getY(), getWidth(), getHeight());
3083-
// CSS backdrop-filter:blur() -- the "liquid glass" effect. Blur whatever has
3084-
// already been painted behind this component (the clip confines it to our
3085-
// bounds) BEFORE our own translucent background and content paint on top. This
3086-
// runs regardless of opacity, since a glass surface is by definition
3087-
// translucent (opaque would be false and skip paintComponentBackground). The
3088-
// port blurs the destination region in place; an unsupported port returns
3089-
// false and the component simply paints without the blur.
3090-
// COST/CACHING POLICY (per paint path):
3091-
// * A glass surface only pays when it repaints; static chrome over static
3092-
// content costs nothing between repaints.
3093-
// * iOS live screen, selection lens: a pure GPU fragment shader on the
3094-
// frame's own command buffer -- no sync, no readback, no cache needed.
3095-
// * iOS live screen, glass material: the backdrop readback is required
3096-
// (the material is a function of the pixels behind the glass), but the
3097-
// composed patch is CACHED per rect+params+backdrop-hash in the port
3098-
// (METALView glass patch cache), so a repaint over an unchanged
3099-
// backdrop skips the colour transform + blur + optics; scrolling
3100-
// content under the glass recomposes that frame from the real bytes.
3101-
// * Offscreen mutable images (capture tooling) and the desktop simulator
3102-
// blur per paint -- capture renders once, and the simulator is not a
3103-
// shipping surface.
3081+
/// Paints the backdrop material (the CSS `backdrop-filter` glass or blur) and the
3082+
/// style background. Called by the paint pipeline right after the component clip
3083+
/// is set; a component whose visible surface is not its bounds (the Tabs bar,
3084+
/// whose glass pill scales inside reserved slack) overrides this and calls
3085+
/// {@link #paintBackgroundLayerAt}.
3086+
void paintBackgroundLayer(Graphics g) {
3087+
paintBackgroundLayerImpl(g);
3088+
}
3089+
3090+
/// Paints {@link #paintBackgroundLayer} into the given rect (parent coordinates,
3091+
/// like getX()/getY()) instead of the bounds. The bounds are swapped on the raw
3092+
/// rectangle so nothing observes the change: setX()/setWidth() would fire
3093+
/// accessibility bounds events on every animation frame.
3094+
final void paintBackgroundLayerAt(Graphics g, int x, int y, int w, int h) {
3095+
int ox = bounds.getX();
3096+
int oy = bounds.getY();
3097+
int ow = bounds.getSize().getWidth();
3098+
int oh = bounds.getSize().getHeight();
3099+
bounds.setX(x);
3100+
bounds.setY(y);
3101+
bounds.getSize().setWidth(w);
3102+
bounds.getSize().setHeight(h);
3103+
try {
3104+
paintBackgroundLayerImpl(g);
3105+
} finally {
3106+
bounds.setX(ox);
3107+
bounds.setY(oy);
3108+
bounds.getSize().setWidth(ow);
3109+
bounds.getSize().setHeight(oh);
3110+
}
3111+
}
3112+
3113+
private void paintBackgroundLayerImpl(Graphics g) {
31043114
float backdropBlur = getStyle().getBackdropFilterBlurRadius();
31053115
if (backdropBlur > 0) {
31063116
// The glass surface's material INTENT comes from a typed, named
@@ -3143,6 +3153,32 @@ void internalPaintImpl(Graphics g, boolean paintIntersects) {
31433153
}
31443154
}
31453155
paintComponentBackground(g);
3156+
}
3157+
3158+
void internalPaintImpl(Graphics g, boolean paintIntersects) {
3159+
g.clipRect(getX(), getY(), getWidth(), getHeight());
3160+
// CSS backdrop-filter:blur() -- the "liquid glass" effect. Blur whatever has
3161+
// already been painted behind this component (the clip confines it to our
3162+
// bounds) BEFORE our own translucent background and content paint on top. This
3163+
// runs regardless of opacity, since a glass surface is by definition
3164+
// translucent (opaque would be false and skip paintComponentBackground). The
3165+
// port blurs the destination region in place; an unsupported port returns
3166+
// false and the component simply paints without the blur.
3167+
// COST/CACHING POLICY (per paint path):
3168+
// * A glass surface only pays when it repaints; static chrome over static
3169+
// content costs nothing between repaints.
3170+
// * iOS live screen, selection lens: a pure GPU fragment shader on the
3171+
// frame's own command buffer -- no sync, no readback, no cache needed.
3172+
// * iOS live screen, glass material: the backdrop readback is required
3173+
// (the material is a function of the pixels behind the glass), but the
3174+
// composed patch is CACHED per rect+params+backdrop-hash in the port
3175+
// (METALView glass patch cache), so a repaint over an unchanged
3176+
// backdrop skips the colour transform + blur + optics; scrolling
3177+
// content under the glass recomposes that frame from the real bytes.
3178+
// * Offscreen mutable images (capture tooling) and the desktop simulator
3179+
// blur per paint -- capture renders once, and the simulator is not a
3180+
// shipping surface.
3181+
paintBackgroundLayer(g);
31463182

31473183
if (isScrollable()) {
31483184
if (refreshTask != null && !InfiniteProgress.isDefaultMaterialDesignMode() &&

‎CodenameOne/src/com/codename1/ui/Container.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2532,6 +2532,15 @@ void markComponentsToBePaintedInElevatedPane(boolean shouldPaintInElevatedPane)
25322532
}
25332533
}
25342534

2535+
/// Runs the layout-on-paint step of {@link #paint(Graphics)} for a subclass that
2536+
/// paints its children itself (the Tabs bar paints each tab twice for the
2537+
/// Liquid Glass selection) and so cannot call super.paint().
2538+
final void layoutForPaint() {
2539+
if (allowEnableLayoutOnPaint && enableLayoutOnPaint) {
2540+
layoutContainer();
2541+
}
2542+
}
2543+
25352544
/// {@inheritDoc}
25362545
@Override
25372546
public void paint(Graphics g) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
* This code is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License version 2 only, as
6+
* published by the Free Software Foundation. Codename One designates this
7+
* particular file as subject to the "Classpath" exception as provided
8+
* by Codename One in the LICENSE file that accompanied this code.
9+
*
10+
* This code is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13+
* version 2 for more details (a copy is included in the LICENSE file that
14+
* accompanied this code).
15+
*
16+
* You should have received a copy of the GNU General Public License version
17+
* 2 along with this work; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19+
*
20+
* Please contact Codename One through http://www.codenameone.com/ if you
21+
* need additional information or have any questions.
22+
*/
23+
package com.codename1.ui;
24+
25+
import com.codename1.util.MathUtil;
26+
27+
/// The free response of a damped harmonic oscillator -- the curve a
28+
/// CASpringAnimation follows -- in closed form.
29+
///
30+
/// `x'' + 2 * zeta * omega * x' + omega^2 * x = 0`, starting at displacement `x0`
31+
/// with velocity `v0`; the spring rests at 0. `omega` is the natural angular
32+
/// frequency (rad/s, `sqrt(stiffness / mass)`) and `zeta` the damping ratio
33+
/// (`damping / (2 * sqrt(stiffness * mass))`): under 1 it oscillates, 1 is
34+
/// critical, over 1 it creeps back without crossing.
35+
///
36+
/// A spring animation from `a` to `b` is `b + displacement(omega, zeta, a - b, v0, t)`.
37+
final class DampedSpring {
38+
private DampedSpring() {
39+
}
40+
41+
/// Displacement from rest at time `t` (seconds).
42+
static double displacement(double omega, double zeta, double x0, double v0, double t) {
43+
if (t <= 0) {
44+
return x0;
45+
}
46+
double decay = MathUtil.exp(-zeta * omega * t);
47+
if (zeta < 1) {
48+
double wd = omega * Math.sqrt(1 - zeta * zeta);
49+
double b = (v0 + zeta * omega * x0) / wd;
50+
return decay * (x0 * Math.cos(wd * t) + b * Math.sin(wd * t));
51+
}
52+
if (zeta == 1) {
53+
return decay * (x0 + (v0 + omega * x0) * t);
54+
}
55+
double s = omega * Math.sqrt(zeta * zeta - 1);
56+
double r1 = -zeta * omega + s;
57+
double r2 = -zeta * omega - s;
58+
double c2 = (v0 - r1 * x0) / (r2 - r1);
59+
double c1 = x0 - c2;
60+
return c1 * MathUtil.exp(r1 * t) + c2 * MathUtil.exp(r2 * t);
61+
}
62+
63+
/// Velocity at time `t` (units per second).
64+
static double velocity(double omega, double zeta, double x0, double v0, double t) {
65+
if (t <= 0) {
66+
return v0;
67+
}
68+
double decay = MathUtil.exp(-zeta * omega * t);
69+
if (zeta < 1) {
70+
double wd = omega * Math.sqrt(1 - zeta * zeta);
71+
double b = (v0 + zeta * omega * x0) / wd;
72+
double c = Math.cos(wd * t);
73+
double s = Math.sin(wd * t);
74+
return decay * ((b * wd - zeta * omega * x0) * c - (x0 * wd + zeta * omega * b) * s);
75+
}
76+
if (zeta == 1) {
77+
double k = v0 + omega * x0;
78+
return decay * (k - omega * (x0 + k * t));
79+
}
80+
double s = omega * Math.sqrt(zeta * zeta - 1);
81+
double r1 = -zeta * omega + s;
82+
double r2 = -zeta * omega - s;
83+
double c2 = (v0 - r1 * x0) / (r2 - r1);
84+
double c1 = x0 - c2;
85+
return c1 * r1 * MathUtil.exp(r1 * t) + c2 * r2 * MathUtil.exp(r2 * t);
86+
}
87+
}

0 commit comments

Comments
 (0)