@rafaelaugusto/jolt 0.1.0-rc.1 → 0.1.0-rc.2

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.
Files changed (36) hide show
  1. package/README.md +39 -11
  2. package/dist/interactive-navigation/index.d.ts.map +1 -1
  3. package/dist/interactive-navigation/index.js +39 -3
  4. package/dist/interactive-navigation/index.js.map +1 -1
  5. package/dist/mixer.d.ts.map +1 -1
  6. package/dist/mixer.js +9 -1
  7. package/dist/mixer.js.map +1 -1
  8. package/dist/physics/index.d.ts +23 -0
  9. package/dist/physics/index.d.ts.map +1 -1
  10. package/dist/physics/index.js +10 -0
  11. package/dist/physics/index.js.map +1 -1
  12. package/dist/physics/native.d.ts +1 -0
  13. package/dist/physics/native.d.ts.map +1 -1
  14. package/dist/physics/native.js +6 -0
  15. package/dist/physics/native.js.map +1 -1
  16. package/dist/press.d.ts.map +1 -1
  17. package/dist/press.js +11 -2
  18. package/dist/press.js.map +1 -1
  19. package/dist/scroll-header/index.d.ts.map +1 -1
  20. package/dist/scroll-header/index.js +36 -10
  21. package/dist/scroll-header/index.js.map +1 -1
  22. package/package.json +1 -1
  23. package/platforms/android/java/org/rafaelaugusto/jolt/CircleMath.java +18 -11
  24. package/platforms/android/java/org/rafaelaugusto/jolt/CircleWorld.java +22 -11
  25. package/platforms/ios/src/JoltCircleMath.h +19 -11
  26. package/platforms/ios/src/JoltCircleWorld.h +1 -0
  27. package/platforms/ios/src/JoltCircleWorld.m +32 -17
  28. package/platforms/ios/src/JoltScrollHeader.m +8 -0
  29. package/src/android-plugin.d.ts +1 -0
  30. package/src/interactive-navigation/index.ts +32 -3
  31. package/src/ios-plugin.d.ts +1 -0
  32. package/src/mixer.ts +7 -1
  33. package/src/physics/index.ts +33 -0
  34. package/src/physics/native.ts +11 -0
  35. package/src/press.ts +10 -2
  36. package/src/scroll-header/index.ts +34 -8
@@ -9,7 +9,7 @@
9
9
  double initialX[16],initialY[16],initialVX[16],initialVY[16],originalX[16],originalY[16];
10
10
  }
11
11
  @property(nonatomic) UIView *container;
12
- @property(nonatomic) NSMutableArray *views,*pans,*guides;
12
+ @property(nonatomic) NSMutableArray *views,*pans,*guides,*pointerGuides;
13
13
  @property(nonatomic) CADisplayLink *link;
14
14
  @property(nonatomic) BOOL enabled,disposed,background,fits;
15
15
  @property(nonatomic) double previous,accumulator,processingMs,droppedMs,offsetX,offsetY;
@@ -18,6 +18,7 @@
18
18
  - (void)refresh;
19
19
  - (void)apply;
20
20
  - (void)cancelDrag;
21
+ - (id)makeGuide:(id)line;
21
22
  - (void)configureAttachment:(id)value index:(int)index;
22
23
  @end
23
24
 
@@ -30,7 +31,7 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
30
31
  NSDictionary *config=[NSJSONSerialization JSONObjectWithData:[options dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
31
32
  NSArray *bodies=config[@"bodies"];
32
33
  if(bodies.count<1||bodies.count>16)[NSException raise:NSInvalidArgumentException format:@"Physics needs 1–16 circles"];
33
- JoltCircleWorld *w=[self new];w.container=container;w.views=[NSMutableArray new];w.pans=[NSMutableArray new];w.guides=[NSMutableArray new];w.dragging=-1;
34
+ JoltCircleWorld *w=[self new];w.container=container;w.views=[NSMutableArray new];w.pans=[NSMutableArray new];w.guides=[NSMutableArray new];w.pointerGuides=[NSMutableArray new];w.dragging=-1;
34
35
  w->state.count=(int)bodies.count;w->state.width=[config[@"width"] doubleValue];w->state.height=[config[@"height"] doubleValue];
35
36
  w->state.gx=[config[@"gravity"][@"x"] doubleValue];w->state.gy=[config[@"gravity"][@"y"] doubleValue];
36
37
  w->state.restitution=[config[@"restitution"] doubleValue];w->state.damping=[config[@"damping"] doubleValue];
@@ -39,8 +40,13 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
39
40
  b->x=[v[@"x"] doubleValue];b->y=[v[@"y"] doubleValue];b->r=[v[@"radius"] doubleValue];b->m=[v[@"mass"] doubleValue];
40
41
  b->vx=[v[@"vx"] doubleValue];b->vy=[v[@"vy"] doubleValue];
41
42
  w->initialX[i]=b->x/w->state.width;w->initialY[i]=b->y/w->state.height;w->initialVX[i]=b->vx;w->initialVY[i]=b->vy;
42
- [w.views addObject:NSNull.null];[w.pans addObject:NSNull.null];[w.guides addObject:NSNull.null];
43
+ [w.views addObject:NSNull.null];[w.pans addObject:NSNull.null];[w.guides addObject:NSNull.null];[w.pointerGuides addObject:NSNull.null];
43
44
  [w configureAttachment:v[@"attachment"] index:i];
45
+ id drag=v[@"drag"];
46
+ if([drag isKindOfClass:NSDictionary.class]){
47
+ b->drag.enabled=true;b->drag.length=[drag[@"length"] doubleValue];b->drag.stiffness=[drag[@"stiffness"] doubleValue];b->drag.damping=[drag[@"damping"] doubleValue];
48
+ w.pointerGuides[i]=[w makeGuide:drag[@"line"]];
49
+ }
44
50
  id snap=v[@"snap"];
45
51
  if([snap isKindOfClass:NSDictionary.class]){
46
52
  NSArray *points=snap[@"points"];if(points.count>JOLT_CIRCLE_SNAP_LIMIT)[NSException raise:NSInvalidArgumentException format:@"Too many snap points"];
@@ -60,13 +66,17 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
60
66
  else JoltCircleAttach(&state,index,false,0,0,0,180,24);
61
67
  if(self.guides[index]!=NSNull.null){[(CALayer *)self.guides[index] removeFromSuperlayer];self.guides[index]=NSNull.null;}
62
68
  id line=active?value[@"line"]:nil;
63
- if([line isKindOfClass:NSDictionary.class]){
64
- CAShapeLayer *guide=[CAShapeLayer layer];unsigned int color=[line[@"color"] unsignedIntValue];
65
- guide.strokeColor=[UIColor colorWithRed:((color>>16)&255)/255.0 green:((color>>8)&255)/255.0 blue:(color&255)/255.0 alpha:1].CGColor;
66
- guide.fillColor=nil;guide.contentsScale=UIScreen.mainScreen.scale;guide.lineWidth=[line[@"width"] doubleValue];guide.lineCap=kCALineCapRound;
67
- [self.container.layer addSublayer:guide];self.guides[index]=guide;
68
- }
69
+ self.guides[index]=[self makeGuide:line];
69
70
  }
71
+ - (id)makeGuide:(id)line {
72
+ if(![line isKindOfClass:NSDictionary.class])return NSNull.null;
73
+ CAShapeLayer *guide=[CAShapeLayer layer];unsigned int color=[line[@"color"] unsignedIntValue];
74
+ guide.strokeColor=[UIColor colorWithRed:((color>>16)&255)/255.0 green:((color>>8)&255)/255.0 blue:(color&255)/255.0 alpha:1].CGColor;
75
+ guide.fillColor=nil;guide.contentsScale=UIScreen.mainScreen.scale;guide.lineWidth=[line[@"width"] doubleValue];guide.lineCap=kCALineCapRound;
76
+ [self.container.layer addSublayer:guide];return guide;
77
+ }
78
+ - (BOOL)supportsPointerSpring {return YES;}
79
+
70
80
  - (void)attach:(NSInteger)index options:(NSString *)options {
71
81
  if(self.disposed||index<0||index>=state.count)return;
72
82
  id value=[NSJSONSerialization JSONObjectWithData:[options dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingFragmentsAllowed error:nil];
@@ -110,13 +120,17 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
110
120
  [v.layer setValue:@(state.bodies[i].x-v.layer.position.x) forKeyPath:@"transform.translation.x"];
111
121
  [v.layer setValue:@(state.bodies[i].y-v.layer.position.y) forKeyPath:@"transform.translation.y"];
112
122
  }
113
- for(int i=0;i<state.count;i++)if(self.guides[i]!=NSNull.null){
114
- JoltCircle *b=&state.bodies[i];CAShapeLayer *guide=self.guides[i];
115
- double x=b->snap.active?JoltCircleSnapX(&state,b,b->snap.index):b->attachment.u*state.width;
116
- double y=b->snap.active?JoltCircleSnapY(&state,b,b->snap.index):b->attachment.v*state.height;
123
+ for(int i=0;i<state.count;i++)for(int kind=0;kind<2;kind++){
124
+ id candidate=(kind?self.pointerGuides:self.guides)[i];if(candidate==NSNull.null)continue;
125
+ JoltCircle *b=&state.bodies[i];CAShapeLayer *guide=candidate;
126
+ BOOL pointer=b->held&&b->drag.enabled;
127
+ guide.hidden=pointer!=(kind==1);if(guide.hidden)continue;
128
+ double x=pointer?b->tx:b->snap.active?JoltCircleSnapX(&state,b,b->snap.index):b->attachment.u*state.width;
129
+ double y=pointer?b->ty:b->snap.active?JoltCircleSnapY(&state,b,b->snap.index):b->attachment.v*state.height;
117
130
  double dx=b->x-x,dy=b->y-y,d=hypot(dx,dy);
118
131
  UIBezierPath *path=[UIBezierPath bezierPath];
119
- if(d>b->r){[path moveToPoint:CGPointMake(x,y)];[path addLineToPoint:CGPointMake(b->x-dx/d*b->r,b->y-dy/d*b->r)];[path appendPath:[UIBezierPath bezierPathWithOvalInRect:CGRectMake(x-2.5,y-2.5,5,5)]];}
132
+ if(d>b->r){[path moveToPoint:CGPointMake(x,y)];[path addLineToPoint:CGPointMake(b->x-dx/d*b->r,b->y-dy/d*b->r)];}
133
+ if(pointer||d>b->r)[path appendPath:[UIBezierPath bezierPathWithOvalInRect:CGRectMake(x-2.5,y-2.5,5,5)]];
120
134
  guide.path=path.CGPath;
121
135
  }
122
136
  [CATransaction commit];
@@ -176,14 +190,15 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
176
190
  - (NSString *)snapshot {
177
191
  NSMutableArray *bodies=[NSMutableArray new];
178
192
  NSUInteger guideCount=0;
179
- for(id guide in self.guides)if(guide!=NSNull.null)guideCount++;
193
+ for(NSArray *list in @[self.guides,self.pointerGuides])for(id guide in list)if(guide!=NSNull.null)guideCount++;
180
194
  for(int i=0;i<state.count;i++){
181
195
  JoltCircle *b=&state.bodies[i];
182
196
  id attachment=b->attachment.enabled?@{@"anchor":@{@"x":@(b->attachment.u*state.width),@"y":@(b->attachment.v*state.height)},@"length":@(b->attachment.length),@"stiffness":@(b->attachment.stiffness),@"damping":@(b->attachment.damping)}:NSNull.null;
183
197
  id snap=NSNull.null;
184
198
  if(b->snap.active){double x=JoltCircleSnapX(&state,b,b->snap.index),y=JoltCircleSnapY(&state,b,b->snap.index);snap=@{@"index":@(b->snap.index),@"point":@{@"x":@(x),@"y":@(y)},@"settled":[NSNumber numberWithBool:hypot(b->x-x,b->y-y)<=.25&&hypot(b->vx,b->vy)<=4&&!b->held]};}
199
+ id pointer=b->held&&b->drag.enabled?@{@"anchor":@{@"x":@(b->tx),@"y":@(b->ty)},@"length":@(b->drag.length),@"stiffness":@(b->drag.stiffness),@"damping":@(b->drag.damping)}:NSNull.null;
185
200
  id release=b->released?@{@"x":@(b->releaseX),@"y":@(b->releaseY),@"vx":@(b->releaseVX),@"vy":@(b->releaseVY)}:NSNull.null;
186
- [bodies addObject:@{@"x":@(b->x),@"y":@(b->y),@"vx":@(b->vx),@"vy":@(b->vy),@"radius":@(b->r),@"held":@(b->held),@"attachment":attachment,@"snap":snap,@"release":release}];
201
+ [bodies addObject:@{@"x":@(b->x),@"y":@(b->y),@"vx":@(b->vx),@"vy":@(b->vy),@"radius":@(b->r),@"held":@(b->held),@"attachment":attachment,@"pointer":pointer,@"snap":snap,@"release":release}];
187
202
  }
188
203
  NSString *status=self.disposed?@"disposed":!self.enabled?@"paused":self.background||!self.container.window?@"detached":!self.fits?@"invalid-bounds":state.sleeping?@"sleeping":@"running";
189
204
  NSDictionary *data=@{@"status":status,@"guides":@(guideCount),@"bodies":bodies,@"frames":@(self.frames),@"steps":@(state.steps),@"contacts":@(state.contacts),@"dragStarts":@(self.dragStarts),@"dragMoves":@(self.dragMoves),@"dragEnds":@(self.dragEnds),@"dragCancels":@(self.dragCancels),@"processingMs":@(self.processingMs),@"droppedMs":@(self.droppedMs),@"clock":[NSNumber numberWithBool:self.link!=nil]};
@@ -197,7 +212,7 @@ static NSMutableSet<JoltCircleWorld *> *worlds;
197
212
  UIView *v=self.views[i];v.userInteractionEnabled=originalInteraction[i];if(self.pans[i]!=NSNull.null)[v removeGestureRecognizer:self.pans[i]];
198
213
  [v.layer setValue:@(originalX[i]) forKeyPath:@"transform.translation.x"];[v.layer setValue:@(originalY[i]) forKeyPath:@"transform.translation.y"];
199
214
  }
200
- for(id guide in self.guides)if(guide!=NSNull.null)[guide removeFromSuperlayer];[self.guides removeAllObjects];
215
+ for(NSMutableArray *list in @[self.guides,self.pointerGuides]){for(id guide in list)if(guide!=NSNull.null)[guide removeFromSuperlayer];[list removeAllObjects];}
201
216
  [CATransaction commit];[self.views removeAllObjects];[self.pans removeAllObjects];self.container=nil;[worlds removeObject:self];
202
217
  }
203
218
  @end
@@ -21,6 +21,7 @@
21
21
  @property(nonatomic,copy) void (^onState)(void);
22
22
  @property(nonatomic) BOOL observing, disposed, titleHidden, toolbarTitleHidden, toolbarTouch;
23
23
  @property(nonatomic) double handoff, minimumOffset, maximumOffset, range, spacer, rowHeight, offset;
24
+ @property(nonatomic) CGFloat estimatedSectionHeaderHeight, estimatedSectionFooterHeight;
24
25
  @property(nonatomic) NSInteger phase, updates, callbacks;
25
26
  @end
26
27
  static NSInteger JoltScrollHeaderActive = 0;
@@ -32,6 +33,11 @@ static void *JoltScrollHeaderContext = &JoltScrollHeaderContext;
32
33
  JoltScrollHeader *owner=[self new];owner.list=list;owner.node=node;owner.title=title;owner.toolbarTitle=toolbarTitle;owner.toolbar=toolbar;owner.diagnostic=diagnostic;owner.onState=onState;
33
34
  owner.titleHidden=title.accessibilityElementsHidden;owner.toolbarTitleHidden=toolbarTitle.accessibilityElementsHidden;owner.toolbarTouch=toolbar.userInteractionEnabled;owner.phase=-1;owner.offset=NAN;
34
35
  owner.proxy=[JoltScrollHeaderDelegate new];owner.proxy.forwarded=list.delegate;
36
+ // Flat lists have no section headers/footers. UIKit's automatic estimates
37
+ // otherwise adjust the scroll anchor during width/safe-area changes.
38
+ owner.estimatedSectionHeaderHeight=list.estimatedSectionHeaderHeight;
39
+ owner.estimatedSectionFooterHeight=list.estimatedSectionFooterHeight;
40
+ list.estimatedSectionHeaderHeight=0;list.estimatedSectionFooterHeight=0;
35
41
  [owner configure:options];list.delegate=owner.proxy;[list reloadData];[list layoutIfNeeded];[owner setEnabled:YES];return owner;
36
42
  }
37
43
  - (void)configure:(NSString *)options {
@@ -88,6 +94,8 @@ static void *JoltScrollHeaderContext = &JoltScrollHeaderContext;
88
94
  if(self.disposed)return;[self setEnabled:NO];self.disposed=YES;
89
95
  self.title.accessibilityElementsHidden=self.titleHidden;self.toolbarTitle.accessibilityElementsHidden=self.toolbarTitleHidden;self.toolbar.userInteractionEnabled=self.toolbarTouch;
90
96
  if(self.list.delegate==self.proxy)self.list.delegate=self.proxy.forwarded;
97
+ if(self.list.estimatedSectionHeaderHeight==0)self.list.estimatedSectionHeaderHeight=self.estimatedSectionHeaderHeight;
98
+ if(self.list.estimatedSectionFooterHeight==0)self.list.estimatedSectionFooterHeight=self.estimatedSectionFooterHeight;
91
99
  self.proxy=nil;self.node=nil;self.onState=nil;
92
100
  }
93
101
  @end
@@ -585,6 +585,7 @@ declare namespace org.rafaelaugusto.jolt {
585
585
  attach(index: number, options: string): void
586
586
  snapTo(index: number, target: number): void
587
587
  reset(): void
588
+ supportsPointerSpring(): boolean
588
589
  snapshot(): string
589
590
  dispose(): void
590
591
  }
@@ -100,6 +100,8 @@ export function interactiveNavigation(
100
100
  let detachTarget: (() => void) | undefined
101
101
  let resolvedReturn: import('@nativescript/core').View | undefined
102
102
  let returning = false
103
+ let suspended = false
104
+ let resumeTimer: ReturnType<typeof setTimeout> | undefined
103
105
  const originalPage = frame.currentPage
104
106
  function prepareShared() {
105
107
  if (!native || !options.shared) return
@@ -210,7 +212,17 @@ export function interactiveNavigation(
210
212
  targetScope?.abort()
211
213
  native?.cancel()
212
214
  }
213
- const unload = () => control.dispose()
215
+ const suspend = () => {
216
+ suspended = true
217
+ if (resumeTimer !== undefined) clearTimeout(resumeTimer)
218
+ resumeTimer = undefined
219
+ cancel()
220
+ }
221
+ const unload = () => {
222
+ // Core unloads the root view in the background, without removing its routes.
223
+ if (suspended) cancel()
224
+ else control.dispose()
225
+ }
214
226
  const shown = () => native?.sync()
215
227
  const swipeChanged = () => {
216
228
  void Promise.resolve().then(shown)
@@ -226,6 +238,17 @@ export function interactiveNavigation(
226
238
  void Promise.resolve().then(() => control.dispose())
227
239
  else shown()
228
240
  }
241
+ const resume = () => {
242
+ suspended = false
243
+ // Core raises resume before reloading the root. A macrotask lets that load
244
+ // finish, including when native callbacks drain JS microtasks in between.
245
+ if (resumeTimer !== undefined) clearTimeout(resumeTimer)
246
+ resumeTimer = setTimeout(() => {
247
+ resumeTimer = undefined
248
+ if (frame.isLoaded) arrived()
249
+ else control.dispose()
250
+ }, 0)
251
+ }
229
252
  const entering = (args: EventData) => {
230
253
  const event = args as NavigationData
231
254
  if (!page && !event.isBack) candidate = event.entry?.resolvedPage
@@ -268,6 +291,8 @@ export function interactiveNavigation(
268
291
  try {
269
292
  return (
270
293
  state === 'idle' &&
294
+ !suspended &&
295
+ frame.isLoaded &&
271
296
  !!start &&
272
297
  !reducedMotion() &&
273
298
  !!page &&
@@ -365,11 +390,14 @@ export function interactiveNavigation(
365
390
  native = undefined
366
391
  emit('disposed')
367
392
  releaseMotion?.()
393
+ if (resumeTimer !== undefined) clearTimeout(resumeTimer)
394
+ resumeTimer = undefined
368
395
  frame.off('unloaded', unload)
369
396
  frame.off('navigatingTo', entering)
370
397
  frame.off('navigatedTo', arrived)
371
398
  frame.off('viewControllerShown', shown)
372
- Application.off(Application.suspendEvent, cancel)
399
+ Application.off(Application.suspendEvent, suspend)
400
+ Application.off(Application.resumeEvent, resume)
373
401
  if (transition?.instance) SharedTransition.finishState(transition.instance.id)
374
402
  page?.off('enableSwipeBackNavigationChange', swipeChanged)
375
403
  start = undefined
@@ -389,7 +417,8 @@ export function interactiveNavigation(
389
417
  frame.on('navigatedTo', arrived)
390
418
  frame.on('viewControllerShown', shown)
391
419
  frame.on('unloaded', unload)
392
- Application.on(Application.suspendEvent, cancel)
420
+ Application.on(Application.suspendEvent, suspend)
421
+ Application.on(Application.resumeEvent, resume)
393
422
  releaseMotion = onReducedMotion((value) => {
394
423
  if (value) cancel()
395
424
  })
@@ -355,6 +355,7 @@ declare class JoltCircleWorld extends NSObject {
355
355
  attachOptions(index: number, options: string): void
356
356
  snapToTarget(index: number, target: number): void
357
357
  reset(): void
358
+ supportsPointerSpring(): boolean
358
359
  snapshot(): string
359
360
  dispose(): void
360
361
  }
package/src/mixer.ts CHANGED
@@ -279,11 +279,17 @@ export class Mix {
279
279
  velocityAt: (elapsed) => (at(elapsed + 0.5) - at(elapsed - 0.5)) * 1000,
280
280
  }
281
281
  writeValue(this.view, this.property, track.to)
282
- this.native = driver.play(this.view, track, now, (finished = true) => {
282
+ const native = driver.play(this.view, track, now, (finished = true) => {
283
283
  if (instance !== this.generation || !finished) return
284
284
  this.native = undefined
285
285
  this.compose()
286
286
  })
287
+ if (instance !== this.generation) return
288
+ this.native = native
289
+ // Building native keyframes can take time. Join the current composition
290
+ // phase after construction, including when a rolling window is replaced.
291
+ const elapsed = driver.now() - now
292
+ if (elapsed > 1) driver.control?.(this.view, native, elapsed, false)
287
293
  }
288
294
 
289
295
  private dissolve() {
@@ -29,6 +29,17 @@ export interface PhysicsSnap {
29
29
  /** Seconds of release-velocity projection for choosing a point, 0–.3; default .12. */
30
30
  projection?: number
31
31
  }
32
+ export interface PhysicsSpringDrag {
33
+ type: 'spring'
34
+ /** Temporary pointer spring rest length in DIPs, 0–10000; default 0. */
35
+ length?: number
36
+ /** Spring force per DIP, 1–1000; default 180. */
37
+ stiffness?: number
38
+ /** Viscous damping, 0–120; default 24. */
39
+ damping?: number
40
+ /** Optional native tether, visible only during the gesture. */
41
+ line?: { color: string; width?: number }
42
+ }
32
43
  export interface CircleBody {
33
44
  id: string
34
45
  /** A dedicated, square direct child of the container, sized to radius * 2. */
@@ -42,6 +53,8 @@ export interface CircleBody {
42
53
  velocity?: PhysicsVector
43
54
  attachment?: PhysicsAttachment
44
55
  snap?: PhysicsSnap
56
+ /** Direct dragging by default. A spring drag keeps the body dynamic during contact. */
57
+ drag?: 'direct' | PhysicsSpringDrag
45
58
  }
46
59
  export interface PhysicsOptions {
47
60
  /** DIPs/s². Defaults to { x: 0, y: 750 }. */
@@ -63,6 +76,13 @@ export interface PhysicsSnapshot {
63
76
  vy: number
64
77
  radius: number
65
78
  held: boolean
79
+ /** Temporary center target: pointer plus the initial center-to-contact offset, in local DIPs. */
80
+ pointer: {
81
+ anchor: PhysicsVector
82
+ length: number
83
+ stiffness: number
84
+ damping: number
85
+ } | null
66
86
  /** Last native release, before snap selection or subsequent integration. */
67
87
  release: { x: number; y: number; vx: number; vy: number } | null
68
88
  attachment: {
@@ -183,6 +203,18 @@ function prepareSnap(
183
203
  projection: number(value.projection ?? 0.12, 'snap.projection', 0, 0.3),
184
204
  }
185
205
  }
206
+ function prepareDrag(value: CircleBody['drag'], width: number, height: number) {
207
+ if (value === undefined || value === 'direct') return null
208
+ if (!value || typeof value !== 'object' || value.type !== 'spring')
209
+ throw new TypeError('Expected direct dragging or a spring drag configuration.')
210
+ // Share the validated spring ranges and line style; the pointer supplies the anchor natively.
211
+ const {
212
+ x: _x,
213
+ y: _y,
214
+ ...spring
215
+ } = prepareAttachment({ ...value, anchor: { x: 0, y: 0 } }, width, height)!
216
+ return spring
217
+ }
186
218
  function untransformed(view: View) {
187
219
  return (
188
220
  (view.translateX ?? 0) === 0 &&
@@ -264,6 +296,7 @@ export function physicsWorld(
264
296
  vy: velocity.y,
265
297
  attachment: prepareAttachment(body.attachment ?? null, width, height),
266
298
  snap: prepareSnap(body.snap, width, height, radius),
299
+ drag: prepareDrag(body.drag, width, height),
267
300
  }
268
301
  })
269
302
  if (!host.isLoaded || !host.nativeViewProtected)
@@ -34,6 +34,7 @@ interface Configuration {
34
34
  vx: number
35
35
  vy: number
36
36
  attachment: NativeAttachment | null
37
+ drag: Omit<NativeAttachment, 'x' | 'y'> | null
37
38
  snap: {
38
39
  points: PhysicsVector[]
39
40
  stiffness: number
@@ -64,6 +65,11 @@ export const createCircleDriver: CircleFactory = (container, views, options) =>
64
65
  try {
65
66
  if (typeof native.attachOptions !== 'function')
66
67
  throw new Error('Rebuild the native app to use physics attachments.')
68
+ if (
69
+ options.bodies.some((b) => b.drag) &&
70
+ typeof native.supportsPointerSpring !== 'function'
71
+ )
72
+ throw new Error('Rebuild the native app to use spring dragging.')
67
73
  views.forEach((v, i) => native.connectIndex(v.nativeViewProtected, i))
68
74
  } catch (e) {
69
75
  native.dispose()
@@ -92,6 +98,11 @@ export const createCircleDriver: CircleFactory = (container, views, options) =>
92
98
  try {
93
99
  if (typeof native.attach !== 'function')
94
100
  throw new Error('Rebuild the native app to use physics attachments.')
101
+ if (
102
+ options.bodies.some((b) => b.drag) &&
103
+ typeof native.supportsPointerSpring !== 'function'
104
+ )
105
+ throw new Error('Rebuild the native app to use spring dragging.')
95
106
  views.forEach((v, i) => native.connect(v.nativeViewProtected, i))
96
107
  } catch (e) {
97
108
  native.dispose()
package/src/press.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
2
2
  import { isColorProperty } from './color.js'
3
- import { animate, startingValues, toView } from './animate.js'
3
+ import { animate, startingValues, stop, toView } from './animate.js'
4
+ import { identity } from './mixer.js'
4
5
  import type { PlaybackAnimation } from './playback.js'
5
6
  import { readValue } from './properties.js'
6
7
  import type { SpringName, Transition } from './transition.js'
@@ -25,6 +26,7 @@ export function press(
25
26
  ): () => void {
26
27
  const view = toView(target)
27
28
  const properties = [...startingValues(pressed, options).keys()]
29
+ const layer = typeof options === 'object' ? options.layer : undefined
28
30
  let rest: Keyframes | undefined
29
31
  let active: PlaybackAnimation | undefined
30
32
 
@@ -34,7 +36,11 @@ export function press(
34
36
  rest = Object.fromEntries(
35
37
  properties.map((p) => [
36
38
  p,
37
- isColorProperty(p) ? new Color(readValue(view, p)) : readValue(view, p),
39
+ layer !== undefined
40
+ ? identity(p)
41
+ : isColorProperty(p)
42
+ ? new Color(readValue(view, p))
43
+ : readValue(view, p),
38
44
  ]),
39
45
  )
40
46
  active = animate(view, pressed, options ?? pressIn)
@@ -52,6 +58,8 @@ export function press(
52
58
 
53
59
  const cancel = () => {
54
60
  active?.stop()
61
+ // A completed press still holds its layer contribution until it is removed.
62
+ if (layer !== undefined) stop(view, properties, { layer })
55
63
  active = undefined
56
64
  rest = undefined
57
65
  }
@@ -152,17 +152,20 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
152
152
  native: ScrollHeaderDriver | undefined,
153
153
  value: SpringValue | undefined
154
154
  let ended = false,
155
+ ready = false,
155
156
  held = false,
156
157
  background = false,
157
158
  queued = false,
159
+ invalidated = false,
158
160
  revision = 0
159
161
  let measuring: Promise<void> | undefined,
162
+ restoringOffset: number | undefined,
160
163
  operation = false
161
164
  let detachBindings: (() => void)[] = [],
162
165
  pending = new Set<() => void>(),
163
166
  last = initialState()
164
167
  const notify = () => {
165
- if (ended) return
168
+ if (ended || !ready) return
166
169
  last = native?.snapshot() ?? last
167
170
  try {
168
171
  options.onState?.(last)
@@ -304,16 +307,18 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
304
307
  options.titleFade,
305
308
  options.toolbarFade,
306
309
  )
307
- if (geometry && JSON.stringify(next) === JSON.stringify(geometry) && native) return
310
+ if (ready && geometry && JSON.stringify(next) === JSON.stringify(geometry) && native)
311
+ return
308
312
  const before = geometry,
309
313
  offset = native?.snapshot().offset ?? (last.updates ? last.offset : undefined)
310
- geometry = next
314
+ ready = false
311
315
  native?.enabled(false)
312
316
  try {
313
317
  native?.configure(next, reducedMotion())
314
318
  options.setSpacerHeight(next.spacer)
315
319
  await layout(list)
316
320
  if (ended || generation !== revision) return
321
+ geometry = next
317
322
  value ??= springValue(motionValue(0), { duration: 0 })
318
323
  maps()
319
324
  if (!native)
@@ -327,20 +332,32 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
327
332
  void Promise.resolve().then(notify)
328
333
  },
329
334
  )
330
- if (offset !== undefined)
331
- native.scroll(before ? resizedOffset(offset, before, next) : offset, false)
335
+ if (offset !== undefined) {
336
+ restoringOffset = before ? resizedOffset(offset, before, next) : offset
337
+ native.scroll(restoringOffset, false)
338
+ await layout(list)
339
+ if (ended || generation !== revision) return
340
+ }
332
341
  native.enabled(!held && !background)
342
+ ready = true
333
343
  notify()
334
344
  } finally {
345
+ restoringOffset = undefined
335
346
  native?.enabled(!held && !background)
336
347
  }
337
348
  }
338
349
  const queueMeasure = () => {
339
- if (ended || queued || measuring || operation) return
350
+ if (ended) return
351
+ if (measuring || operation) {
352
+ invalidated = true
353
+ return
354
+ }
355
+ if (queued) return
340
356
  queued = true
341
357
  void Promise.resolve()
342
358
  .then(() => {
343
359
  queued = false
360
+ invalidated = false
344
361
  return control.remeasure()
345
362
  })
346
363
  .catch((error) => {
@@ -350,8 +367,10 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
350
367
  }
351
368
  const detach = () => {
352
369
  revision++
370
+ ready = false
353
371
  for (const cancel of [...pending]) cancel()
354
372
  last = native?.snapshot() ?? last
373
+ if (restoringOffset !== undefined) last = { ...last, offset: restoringOffset }
355
374
  native?.dispose()
356
375
  native = undefined
357
376
  detachBindings.splice(0).forEach((off) => off())
@@ -373,7 +392,7 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
373
392
  })
374
393
  const control: ScrollHeader = {
375
394
  get ready() {
376
- return !!native && !ended
395
+ return ready && !ended
377
396
  },
378
397
  get disposed() {
379
398
  return ended
@@ -385,9 +404,16 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
385
404
  return geometry
386
405
  },
387
406
  remeasure() {
407
+ invalidated = true
388
408
  if (measuring) return measuring
389
- measuring = measure().finally(() => {
409
+ measuring = (async () => {
410
+ do {
411
+ invalidated = false
412
+ await measure()
413
+ } while (invalidated && !ended && list.isLoaded && !operation)
414
+ })().finally(() => {
390
415
  measuring = undefined
416
+ if (invalidated) queueMeasure()
391
417
  })
392
418
  return measuring
393
419
  },