Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Fixed the ability to scroll to the top on iOS 13 #16820

Merged
merged 11 commits into from
Mar 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,6 @@ FLUTTER_EXPORT

@property(strong, nonatomic) UIWindow* window;

/**
* Handle StatusBar touches.
*
* Call this from your AppDelegate's `touchesBegan:withEvent:` to have Flutter respond to StatusBar
* touches. For example, to enable scroll-to-top behavior. FlutterAppDelegate already calls it so
* you only need to manually call it if you aren't using a FlutterAppDelegate.
*/
+ (void)handleStatusBarTouches:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event;

@end

#endif // FLUTTER_FLUTTERDARTPROJECT_H_
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ FLUTTER_EXPORT
nibName:(nullable NSString*)nibName
bundle:(nullable NSBundle*)nibBundle NS_DESIGNATED_INITIALIZER;

- (void)handleStatusBarTouches:(UIEvent*)event;

/**
* Registers a callback that will be invoked when the Flutter view has been rendered.
* The callback will be fired only once.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,6 @@ + (FlutterViewController*)rootFlutterViewController {
return nil;
}

+ (void)handleStatusBarTouches:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
[self.rootFlutterViewController handleStatusBarTouches:event];
}

- (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
[super touchesBegan:touches withEvent:event];
[[self class] handleStatusBarTouches:touches withEvent:event];
}

// Do not remove, some clients may be calling these via `super`.
- (void)applicationDidEnterBackground:(UIApplication*)application {
}
Expand Down
85 changes: 47 additions & 38 deletions shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
#import "flutter/shell/platform/darwin/ios/framework/Source/platform_message_response_darwin.h"
#import "flutter/shell/platform/darwin/ios/platform_view_ios.h"

static constexpr int kMicrosecondsPerSecond = 1000 * 1000;
static constexpr CGFloat kScrollViewContentSize = 2.0;

NSNotificationName const FlutterSemanticsUpdateNotification = @"FlutterSemanticsUpdate";

NSNotificationName const FlutterViewControllerWillDealloc = @"FlutterViewControllerWillDealloc";
Expand Down Expand Up @@ -86,7 +89,7 @@ - (void)invalidate {
// This is left a FlutterBinaryMessenger privately for now to give people a chance to notice the
// change. Unfortunately unless you have Werror turned on, incompatible pointers as arguments are
// just a warning.
@interface FlutterViewController () <FlutterBinaryMessenger>
@interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegate>
Copy link
Member

Choose a reason for hiding this comment

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

Add a comment for the interface like binary messenger

Copy link
Member Author

Choose a reason for hiding this comment

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

We don't document every time we implement a protocol in the codebase. FlutterBinaryMessenger was a special case since it was a special migration step.

@property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI;
@end

Expand Down Expand Up @@ -123,6 +126,11 @@ @implementation FlutterViewController {
// Coalescer that filters out superfluous keyboard notifications when the app
// is being foregrounded.
fml::scoped_nsobject<FlutterCoalescer> _updateViewportMetrics;
// This scroll view is a workaround to accomodate iOS 13 and higher. There isn't a way to get
// touches on the status bar to trigger scrolling to the top of a scroll view. We place a
// UIScrollView with height zero and a content offset so we can get those events. See also:
// https://github.com/flutter/flutter/issues/35050
fml::scoped_nsobject<UIScrollView> _scrollView;
Copy link
Member

Choose a reason for hiding this comment

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

Add a comment for why we own a uiscrollview

Copy link
Member Author

Choose a reason for hiding this comment

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

All of that information is documented at the site of instantiation. We don't have a practice of documenting every instance variable, it would just be a duplication of what is said at the instantiation site.

Copy link
Member

Choose a reason for hiding this comment

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

I think that documentation is better served here than down below. If you see a _scrollView and don't know where it is, you can go-to-definition to see the reasoning. It's less direct to see where the scoped_nsobject is set and then find the local variable's construction which populates it.

You can also add one more line to say that UIScrollView specifically has a https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619378-scrollviewshouldscrolltotop callback which we game here as a hint for future maintainers.

Copy link
Member Author

Choose a reason for hiding this comment

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

done

}

@synthesize displayingFlutterUI = _displayingFlutterUI;
Expand Down Expand Up @@ -329,6 +337,40 @@ - (void)loadView {
self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;

[self installSplashScreenViewIfNecessary];
UIScrollView* scrollView = [[UIScrollView alloc] init];
Copy link
Member

Choose a reason for hiding this comment

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

Comment for why in needs to be a scrollview rather than any uiview

Copy link
Member Author

Choose a reason for hiding this comment

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

That information is already included in the current comment.

scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth;
// The color shouldn't matter since it is offscreen.
scrollView.backgroundColor = UIColor.whiteColor;
scrollView.delegate = self;
// This is an arbitrary small size.
scrollView.contentSize = CGSizeMake(kScrollViewContentSize, kScrollViewContentSize);
// This is an arbitrary offset that is not CGPointZero.
scrollView.contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize);
[self.view addSubview:scrollView];
_scrollView.reset(scrollView);
}

static void sendFakeTouchEvent(FlutterEngine* engine,
CGPoint location,
flutter::PointerData::Change change) {
const CGFloat scale = [UIScreen mainScreen].scale;
flutter::PointerData pointer_data;
pointer_data.Clear();
pointer_data.physical_x = location.x * scale;
pointer_data.physical_y = location.y * scale;
pointer_data.kind = flutter::PointerData::DeviceKind::kTouch;
pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond;
auto packet = std::make_unique<flutter::PointerDataPacket>(/*count=*/1);
pointer_data.change = change;
packet->SetPointerData(0, pointer_data);
[engine dispatchPointerDataPacket:std::move(packet)];
}

- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
CGPoint statusBarPoint = CGPointZero;
sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kDown);
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldnt it make more sense to send a message to flutter instead of sending fake touch events? @xster

Copy link
Member Author

Choose a reason for hiding this comment

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

FWIW I did it this way since thats how the code was previously working. I tried to change as little as possible.

Copy link
Contributor

@Kavantix Kavantix Mar 4, 2020

Choose a reason for hiding this comment

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

I asked heaving in mind that flutter/flutter#42560 will be implemented somewhere in the future.
But it might make more sense to change the way the tap is handled on the dart side.
I believe currently it is done by the scaffold and should probably be moved to the routes.

@xster should I make a separate issue for this or add a comment to the one linked?

Copy link
Member

Choose a reason for hiding this comment

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

this is a bit pre-emptive but it would be nice to check whether the flutter vc is top aligned before doing it (so we don't scroll if it's nested in the middle of the page).

Copy link
Member Author

Choose a reason for hiding this comment

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

This is unnecessary because the statusbar is guaranteed to be top aligned if visible. If it isn't visible, the call to scrollViewShouldScrollToTop won't be called.

Copy link
Member

Choose a reason for hiding this comment

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

Rather than being invisible, I meant if Flutter was embedded as a partial view in another parent native view. Would Flutter get this event even though it's not at the top of the screen? Maybe it already does the right thing.

Copy link
Member Author

Choose a reason for hiding this comment

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

The scrollview doesn't need to be at the top of the screen, but it has to be on screen in order to get the event.

Copy link
Member

Choose a reason for hiding this comment

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

I meant rather that if Flutter was embedded as an entry in a native table, and you tap the top of the screen, you wouldn't want to send this synthesized touch to Flutter

sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kUp);
return NO;
}

#pragma mark - Managing launch views
Expand Down Expand Up @@ -569,7 +611,6 @@ - (void)flushOngoingTouches {
flutter::PointerData pointer_data;
pointer_data.Clear();

constexpr int kMicrosecondsPerSecond = 1000 * 1000;
// Use current time.
pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond;

Expand Down Expand Up @@ -835,6 +876,10 @@ - (void)viewDidLayoutSubviews {
CGSize viewSize = self.view.bounds.size;
CGFloat scale = [UIScreen mainScreen].scale;

// Purposefully place this not visible.
_scrollView.get().frame = CGRectMake(0.0, 0.0, viewSize.width, 0.0);
_scrollView.get().contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize);

// First time since creation that the dimensions of its view is known.
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
_viewportMetrics.device_pixel_ratio = scale;
Expand Down Expand Up @@ -1146,42 +1191,6 @@ - (NSString*)contrastMode {
}
}

#pragma mark - Status Bar touch event handling

// Standard iOS status bar height in points.
constexpr CGFloat kStandardStatusBarHeight = 20.0;

- (void)handleStatusBarTouches:(UIEvent*)event {
CGFloat standardStatusBarHeight = kStandardStatusBarHeight;
if (@available(iOS 11, *)) {
standardStatusBarHeight = self.view.safeAreaInsets.top;
}

// If the status bar is double-height, don't handle status bar taps. iOS
// should open the app associated with the status bar.
CGRect statusBarFrame = [UIApplication sharedApplication].statusBarFrame;
if (statusBarFrame.size.height != standardStatusBarHeight) {
return;
}

// If we detect a touch in the status bar, synthesize a fake touch begin/end.
for (UITouch* touch in event.allTouches) {
if (touch.phase == UITouchPhaseBegan && touch.tapCount > 0) {
CGPoint windowLoc = [touch locationInView:nil];
CGPoint screenLoc = [touch.window convertPoint:windowLoc toWindow:nil];
if (CGRectContainsPoint(statusBarFrame, screenLoc)) {
NSSet* statusbarTouches = [NSSet setWithObject:touch];

flutter::PointerData::Change change = flutter::PointerData::Change::kDown;
[self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change];
change = flutter::PointerData::Change::kUp;
[self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change];
return;
}
}
}
}

#pragma mark - Status bar style

- (UIStatusBarStyle)preferredStatusBarStyle {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
0A57B3BF2323C74200DD9521 /* FlutterEngine+ScenariosTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */; };
0A57B3C22323D2D700DD9521 /* AppLifecycleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */; };
0D14A3FE239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png in Resources */ = {isa = PBXBuildFile; fileRef = 0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */; };
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D8470A3240F0B1F0030B565 /* StatusBarTest.m */; };
0DB781EF22E931BE00E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
0DB781F122E933E800E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
0DB781FE22EA2C6D00E9B371 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; };
Expand Down Expand Up @@ -114,6 +115,8 @@
0A57B3C02323C74D00DD9521 /* FlutterEngine+ScenariosTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FlutterEngine+ScenariosTest.h"; sourceTree = "<group>"; };
0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppLifecycleTests.m; sourceTree = "<group>"; };
0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "golden_platform_view_rotate_iPhone SE_simulator.png"; sourceTree = "<group>"; };
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StatusBarTest.h; sourceTree = "<group>"; };
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StatusBarTest.m; sourceTree = "<group>"; };
0DB781FC22EA2C0300E9B371 /* FlutterViewControllerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FlutterViewControllerTest.m; sourceTree = "<group>"; };
244EA6CF230DBE8900B2D26E /* golden_platform_view_D21AP.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = golden_platform_view_D21AP.png; sourceTree = "<group>"; };
246B4E4122E3B5F700073EBF /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = App.framework; sourceTree = "<group>"; };
Expand Down Expand Up @@ -272,6 +275,8 @@
6816DBA22318358200A51400 /* PlatformViewGoldenTestManager.h */,
6816DBA32318358200A51400 /* PlatformViewGoldenTestManager.m */,
68A5B63323EB71D300BDBCDB /* PlatformViewGestureRecognizerTests.m */,
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */,
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */,
);
path = ScenariosUITests;
sourceTree = "<group>";
Expand Down Expand Up @@ -466,6 +471,7 @@
6816DB9E231750ED00A51400 /* GoldenPlatformViewTests.m in Sources */,
6816DBA42318358200A51400 /* PlatformViewGoldenTestManager.m in Sources */,
248D76EF22E388380012F0C1 /* PlatformViewUITests.m in Sources */,
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
23 changes: 21 additions & 2 deletions testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ - (BOOL)application:(UIApplication*)application
@"--gesture-reject-after-touches-ended" : @"platform_view_gesture_reject_after_touches_ended",
@"--gesture-reject-eager" : @"platform_view_gesture_reject_eager",
@"--gesture-accept" : @"platform_view_gesture_accept",
@"--tap-status-bar" : @"tap_status_bar",
};
__block NSString* platformViewTestName = nil;
[launchArgsMap
Expand All @@ -67,15 +68,33 @@ - (void)readyContextForPlatformViewTests:(NSString*)scenarioIdentifier {
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"PlatformViewTest" project:nil];
[engine runWithEntrypoint:nil];

FlutterViewController* flutterViewController =
[[NoStatusBarFlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
FlutterViewController* flutterViewController;
if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) {
flutterViewController = [[FlutterViewController alloc] initWithEngine:engine
nibName:nil
bundle:nil];
} else {
flutterViewController = [[NoStatusBarFlutterViewController alloc] initWithEngine:engine
nibName:nil
bundle:nil];
}
[engine.binaryMessenger
setMessageHandlerOnChannel:@"scenario_status"
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
[engine.binaryMessenger
sendOnChannel:@"set_scenario"
message:[scenarioIdentifier dataUsingEncoding:NSUTF8StringEncoding]];
}];
[engine.binaryMessenger
setMessageHandlerOnChannel:@"touches_scenario"
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message
options:0
error:nil];
UITextField* text = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 300, 100)];
text.text = dict[@"change"];
[flutterViewController.view addSubview:text];
}];
TextPlatformViewFactory* textPlatformViewFactory =
[[TextPlatformViewFactory alloc] initWithMessenger:flutterViewController.binaryMessenger];
NSObject<FlutterPluginRegistrar>* registrar =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright 2020 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import <XCTest/XCTest.h>

NS_ASSUME_NONNULL_BEGIN

@interface StatusBarTest : XCTestCase
@property(nonatomic, strong) XCUIApplication* application;
@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2020 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "StatusBarTest.h"

@implementation StatusBarTest

- (void)setUp {
[super setUp];
self.continueAfterFailure = NO;

self.application = [[XCUIApplication alloc] init];
self.application.launchArguments = @[ @"--tap-status-bar" ];
[self.application launch];
}

- (void)testTapStatusBar {
if (@available(iOS 13, *)) {
XCUIApplication* systemApp =
[[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"];
Copy link
Member

Choose a reason for hiding this comment

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

😮 so they straight up moved the status bar out of the app and added some special hook to the uiscrollview to be able to catch it?

Copy link
Member Author

Choose a reason for hiding this comment

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

The hook for uiscrollviews always existed (since like ios 2.0), they didn't break that one.

XCUIElement* statusBar = [systemApp.statusBars firstMatch];
if (statusBar.isHittable) {
[statusBar tap];
} else {
XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)];
[coordinates tap];
}
} else {
[[self.application.statusBars firstMatch] tap];
}

XCUIElement* addTextField = self.application.textFields[@"PointerChange.add"];
Copy link
Member

Choose a reason for hiding this comment

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

oh ha. Though can't you just put the channel handler you had in the AppDelegate in this test itself? Then you can just [self waitForExpectations] against its own listener like AppLifecycleTests?

Copy link
Member Author

Choose a reason for hiding this comment

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

You can't do that. AppLifecycleTests are unit tests and run in the same process as the app. UI tests run as a separate process, I'd have to do IPC to communicate between the processes. There is a builtin mechanism to do IPC for UI elements though so that's what I'm using here.

Copy link
Member

Choose a reason for hiding this comment

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

Doh, you're right, this is a XCUITest

BOOL exists = [addTextField waitForExistenceWithTimeout:1];
XCTAssertTrue(exists, @"");
XCUIElement* upTextField = self.application.textFields[@"PointerChange.up"];
exists = [upTextField waitForExistenceWithTimeout:1];
XCTAssertTrue(exists, @"");
}

@end
2 changes: 2 additions & 0 deletions testing/scenario_app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'src/animated_color_square.dart';
import 'src/platform_view.dart';
import 'src/poppable_screen.dart';
import 'src/scenario.dart';
import 'src/touches_scenario.dart';

Map<String, Scenario> _scenarios = <String, Scenario>{
'animated_color_square': AnimatedColorSquareScenario(window),
Expand All @@ -30,6 +31,7 @@ Map<String, Scenario> _scenarios = <String, Scenario>{
'platform_view_gesture_reject_eager': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false),
'platform_view_gesture_accept': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: true),
'platform_view_gesture_reject_after_touches_ended': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false, rejectUntilTouchesEnded: true),
'tap_status_bar' : TouchesScenario(window),
};

Scenario _currentScenario = _scenarios['animated_color_square'];
Expand Down
31 changes: 31 additions & 0 deletions testing/scenario_app/lib/src/touches_scenario.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2020 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:ui';

import 'scenario.dart';

/// A scenario that sends back messages when touches are received.
class TouchesScenario extends Scenario {
/// Constructor for `TouchesScenario`.
TouchesScenario(Window window) : super(window);

@override
void onBeginFrame(Duration duration) {}

@override
void onPointerDataPacket(PointerDataPacket packet) {
window.sendPlatformMessage(
'touches_scenario',
utf8.encoder
.convert(const JsonCodec().encode(<String, dynamic>{
'change': packet.data[0].change.toString(),
}))
.buffer
.asByteData(),
null,
);
}
}