Files
ppsspp/ios/ViewControllerCommon.mm
T
Henrik RydgårdandClaude Opus 5.5 b4775cde07 iOS: Fix the safe area inset and touch scaling
The safe area insets were converted to pixels with UIScreen.scale, but
the display size with nativeScale. The two differ on the Plus models
and the minis, so the insets were off by up to 15% there. Both use the
window scene's nativeScale now.

Touch positions were truncated to integers before being multiplied by
the screen scale, putting them on a grid about 3 dp apart. Convert
straight from points to dp, by the view size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:28:27 +02:00

575 lines
19 KiB
Plaintext

#import "ios/CameraHelper.h"
#import "ios/ViewControllerCommon.h"
#import "ios/Controls.h"
#import "ios/IAPManager.h"
#import <Photos/Photos.h>
#import <objc/runtime.h>
#include "Common/System/Request.h"
#include "Common/Input/InputState.h"
#include "Common/System/NativeApp.h"
#include "Common/System/Display.h"
#include "Common/Log.h"
#include "Core/HLE/sceUsbCam.h"
#include "Core/HLE/sceUsbGps.h"
#include "Core/System.h"
#include "Core/Config.h"
@interface PPSSPPBaseViewController () {
CameraHelper *cameraHelper;
LocationHelper *locationHelper;
ICadeTracker g_iCadeTracker;
TouchTracker g_touchTracker;
}
@property (strong, nonatomic) NSOperationQueue *accelerometerQueue;
@property (nonatomic) GCController *gameController;
@property (strong, nonatomic) CMMotionManager *motionManager;
@end
@implementation PPSSPPBaseViewController {
UIScreenEdgePanGestureRecognizer *mBackGestureRecognizer;
CGSize lastLayoutSize_;
}
// Strange idiom for generating unique IDs (within the process, at least).
static const void *kPickerFilenameKey = &kPickerFilenameKey;
static const void *kPickerRequestIdKey = &kPickerRequestIdKey;
static void SetPickerContext(id picker, NSString *filename, int requestId) {
objc_setAssociatedObject(picker, kPickerFilenameKey, filename, OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(picker, kPickerRequestIdKey, @(requestId), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static NSString *GetPickerFilename(id picker) {
return (NSString *)objc_getAssociatedObject(picker, kPickerFilenameKey);
}
static int GetPickerRequestId(id picker) {
NSNumber *requestIdNumber = (NSNumber *)objc_getAssociatedObject(picker, kPickerRequestIdKey);
return requestIdNumber ? requestIdNumber.intValue : -1;
}
- (void)savePickedImage:(UIImage *)image toFilename:(NSString *)targetFilename requestId:(int)requestId {
NSData *jpegData = UIImageJPEGRepresentation(image, 0.9);
if (jpegData) {
[jpegData writeToFile:targetFilename atomically:YES];
NSLog(@"Saved JPEG image to %@", targetFilename);
g_requestManager.PostSystemSuccess(requestId, "", 1);
} else {
g_requestManager.PostSystemFailure(requestId);
}
}
- (id)init {
self = [super init];
if (self) {
sharedViewController = self;
g_iCadeTracker.InitKeyMap();
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillTerminate:) name:UIApplicationWillTerminateNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidConnect:) name:GCControllerDidConnectNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil];
// Observe orientation changes
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onOrientationChanged)
name:UIDeviceOrientationDidChangeNotification
object:nil];
self.accelerometerQueue = [[NSOperationQueue alloc] init];
self.accelerometerQueue.name = @"AccelerometerQueue";
self.accelerometerQueue.maxConcurrentOperationCount = 1;
}
return self;
}
// Only means anything for the GL backend, which overrides this.
- (void)bindDefaultFBO {}
- (void)shutdown {
self.gameController = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
_dbg_assert_(sharedViewController != nil);
sharedViewController = nil;
}
- (BOOL)prefersHomeIndicatorAutoHidden {
if (g_Config.iAppSwitchMode == (int)AppSwitchMode::DOUBLE_SWIPE_INDICATOR) {
return NO;
} else {
return YES;
}
}
- (void)didBecomeActive {
if (self.motionManager.accelerometerAvailable) {
self.motionManager.accelerometerUpdateInterval = 1.0 / 60.0;
INFO_LOG(Log::G3D, "Starting accelerometer updates.");
[self.motionManager startAccelerometerUpdatesToQueue:self.accelerometerQueue
withHandler:^(CMAccelerometerData *accelerometerData, NSError *error) {
if (error) {
NSLog(@"Accelerometer error: %@", error);
return;
}
ProcessAccelerometerData(accelerometerData);
}];
} else {
INFO_LOG(Log::G3D, "No accelerometer available, not starting updates.");
}
}
- (void)willResignActive {
// Stop accelerometer updates
if (self.motionManager.accelerometerActive) {
INFO_LOG(Log::G3D, "Stopping accelerometer updates");
[self.motionManager stopAccelerometerUpdates];
}
}
- (void)appWillTerminate:(NSNotification *)notification {
[self shutdown];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
INFO_LOG(Log::G3D, "viewDidAppear");
[self hideKeyboard];
[self updateGesture];
// This needs to be called really late during startup, unfortunately.
#if PPSSPP_PLATFORM(IOS_APP_STORE)
[IAPManager sharedIAPManager]; // Kick off the IAPManager early.
NSLog(@"viewDidAppear. updating icon");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[[IAPManager sharedIAPManager] updateIcon:false];
[self hideKeyboard];
});
#endif // IOS_APP_STORE
}
// Enables tapping for edge area.
-(UIRectEdge)preferredScreenEdgesDeferringSystemGestures {
if (GetUIState() == UISTATE_INGAME) {
// In-game, we need all the control we can get. Though, we could possibly
// allow the top edge?
INFO_LOG(Log::System, "Defer system gestures on all edges");
return UIRectEdgeAll;
} else {
INFO_LOG(Log::System, "Allow system gestures on the bottom");
// Allow task switching gestures to take precedence, without causing
// scroll events in the UI. Otherwise, we get "ghost" scrolls when switching tasks.
return UIRectEdgeTop | UIRectEdgeLeft | UIRectEdgeRight;
}
}
- (void)controllerDidConnect:(NSNotification *)note
{
if (![[GCController controllers] containsObject:self.gameController]) self.gameController = nil;
if (self.gameController != nil) return; // already have a connected controller
[self setupController:(GCController *)note.object];
}
- (void)controllerDidDisconnect:(NSNotification *)note
{
if (self.gameController == note.object) {
ShutdownController(self.gameController);
self.gameController = nil;
if ([[GCController controllers] count] > 0) {
[self setupController:[[GCController controllers] firstObject]];
}
}
}
- (void)setupController:(GCController *)controller {
self.gameController = controller;
if (!InitController(controller)) {
self.gameController = nil;
}
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Began(touches, self.view);
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Moved(touches, self.view);
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Ended(touches, self.view);
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
g_touchTracker.Cancelled(touches, self.view);
}
- (void)pickPhoto:(NSString *)saveFilename requestId:(int)requestId {
NSLog(@"Picking photo to save to %@ (id: %d)", saveFilename, requestId);
NSString *targetFilename = [saveFilename copy];
if (@available(iOS 14.0, *)) {
PHPickerConfiguration *config = [[PHPickerConfiguration alloc] initWithPhotoLibrary:PHPhotoLibrary.sharedPhotoLibrary];
config.selectionLimit = 1;
config.filter = [PHPickerFilter imagesFilter];
PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:config];
picker.delegate = self;
SetPickerContext(picker, targetFilename, requestId);
[self presentViewController:picker animated:YES completion:nil];
return;
}
UIImagePickerController *picker = [[UIImagePickerController alloc] init];
picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
// Images only keeps the picker from doing extra video-related setup.
picker.mediaTypes = @[@"public.image"];
picker.delegate = self;
SetPickerContext(picker, targetFilename, requestId);
[self presentViewController:picker animated:YES completion:nil];
}
- (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray<PHPickerResult *> *)results API_AVAILABLE(ios(14.0)) {
NSString *targetFilename = GetPickerFilename(picker);
int requestId = GetPickerRequestId(picker);
[picker dismissViewControllerAnimated:YES completion:nil];
if (results.count == 0) {
NSLog(@"User cancelled photo picker");
g_requestManager.PostSystemFailure(requestId);
[self hideKeyboard];
return;
}
NSItemProvider *itemProvider = results.firstObject.itemProvider;
if (![itemProvider canLoadObjectOfClass:[UIImage class]]) {
NSLog(@"Photo picker result does not provide UIImage");
g_requestManager.PostSystemFailure(requestId);
[self hideKeyboard];
return;
}
[itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(UIImage *image, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error || !image) {
NSLog(@"Failed loading picked image: %@", error);
g_requestManager.PostSystemFailure(requestId);
} else {
[self savePickedImage:image toFilename:targetFilename requestId:requestId];
}
[self hideKeyboard];
});
}];
}
- (void)imagePickerController:(UIImagePickerController *)picker
didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info {
NSString *targetFilename = GetPickerFilename(picker);
int requestId = GetPickerRequestId(picker);
UIImage *image = info[UIImagePickerControllerOriginalImage];
[self savePickedImage:image toFilename:targetFilename requestId:requestId];
[picker dismissViewControllerAnimated:YES completion:nil];
[self hideKeyboard];
}
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
NSLog(@"User cancelled image picker");
int requestId = GetPickerRequestId(picker);
[picker dismissViewControllerAnimated:YES completion:nil];
// You can also call your custom callback or use the requestId here
g_requestManager.PostSystemFailure(requestId);
[self hideKeyboard];
}
- (void)handleSwipeFrom:(UIScreenEdgePanGestureRecognizer *)recognizer {
if (recognizer.state == UIGestureRecognizerStateEnded) {
KeyInput key;
key.flags = KeyInputFlags::DOWN | KeyInputFlags::UP;
key.keyCode = NKCODE_BACK;
key.deviceId = DEVICE_ID_TOUCH;
NativeKey(key);
INFO_LOG(Log::System, "Detected back swipe");
}
}
- (void)updateGesture {
INFO_LOG(Log::System, "Updating swipe gesture.");
if (mBackGestureRecognizer) {
INFO_LOG(Log::System, "Removing swipe gesture.");
[[self view] removeGestureRecognizer:mBackGestureRecognizer];
mBackGestureRecognizer = nil;
}
if (GetUIState() != UISTATE_INGAME) {
INFO_LOG(Log::System, "Adding swipe gesture.");
mBackGestureRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipeFrom:) ];
[mBackGestureRecognizer setEdges:UIRectEdgeLeft];
[[self view] addGestureRecognizer:mBackGestureRecognizer];
}
}
- (void)uiStateChanged {
[self setNeedsUpdateOfScreenEdgesDeferringSystemGestures];
[self hideKeyboard];
[self updateGesture];
}
- (void)startVideo:(int)width height:(int)height {
[cameraHelper startVideo:width h:height];
}
- (void)stopVideo {
[cameraHelper stopVideo];
}
- (void)PushCameraImageIOS:(long long)len buffer:(unsigned char*)data {
Camera::pushCameraImage(len, data);
}
- (void)startLocation {
[locationHelper startLocationUpdates];
}
- (void)stopLocation {
[locationHelper stopLocationUpdates];
}
- (void)SetGpsDataIOS:(CLLocation *)newLocation {
GPS::setGpsData((long long)newLocation.timestamp.timeIntervalSince1970,
newLocation.horizontalAccuracy/5.0,
newLocation.coordinate.latitude, newLocation.coordinate.longitude,
newLocation.altitude,
MAX(newLocation.speed * 3.6, 0.0), /* m/s to km/h */
0 /* bearing */);
}
- (void)viewDidLoad {
[super viewDidLoad];
cameraHelper = [[CameraHelper alloc] init];
[cameraHelper setDelegate:self];
locationHelper = [[LocationHelper alloc] init];
[locationHelper setDelegate:self];
self.motionManager = [[CMMotionManager alloc] init];
}
extern float g_safeInsetLeft;
extern float g_safeInsetRight;
extern float g_safeInsetTop;
extern float g_safeInsetBottom;
- (void)viewSafeAreaInsetsDidChange {
[super viewSafeAreaInsetsDidChange];
// Converts points to pixels, the same way updateResolutionWithView does.
CGFloat scale = [self displayScaleForView:self.view];
float xInsetSum = self.view.safeAreaInsets.left + self.view.safeAreaInsets.right;
float yInsetSum = self.view.safeAreaInsets.top + self.view.safeAreaInsets.bottom;
// Only use the larger set of insets. The other set, we'll handle through layouts.
// Also, we'll treat the bottom inset differently: We'll add some space at the end of everything
// that scrolls so that when you're not at the bottom, we use the full vertical area,
// just looks better.
if (xInsetSum > yInsetSum) {
// Landscape mode insets are larger, use those.
g_safeInsetLeft = self.view.safeAreaInsets.left * scale;
g_safeInsetRight = self.view.safeAreaInsets.right * scale;
g_safeInsetTop = 0.0f;
g_safeInsetBottom = 0.0f;
} else {
// Portrait mode insets are larger, use those.
g_safeInsetLeft = 0.0f;
g_safeInsetRight = 0.0f;
g_safeInsetTop = self.view.safeAreaInsets.top * scale;
g_safeInsetBottom = self.view.safeAreaInsets.bottom * scale;
}
}
- (void)shareText:(NSString *)text {
NSArray *items = @[text];
UIActivityViewController *viewController = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
// On iPad, the share sheet is a popover and needs an anchor.
viewController.popoverPresentationController.sourceView = self.view;
viewController.popoverPresentationController.sourceRect = CGRectMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds), 0, 0);
viewController.popoverPresentationController.permittedArrowDirections = 0;
[self presentViewController:viewController animated:YES completion:nil];
}
- (void)appSwitchModeChanged {
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
}
// The below is inspired by https://stackoverflow.com/questions/7253477/how-to-display-the-iphone-ipad-keyboard-over-a-full-screen-opengl-es-app
// It's a bit limited but good enough.
- (void)deleteBackward {
KeyInput input{};
input.deviceId = DEVICE_ID_KEYBOARD;
input.flags = KeyInputFlags::DOWN | KeyInputFlags::UP;
input.keyCode = NKCODE_DEL;
NativeKey(input);
INFO_LOG(Log::System, "Backspace");
}
- (void)insertText:(NSString *)text {
std::string str([text UTF8String]);
INFO_LOG(Log::System, "Chars: %s", str.c_str());
SendKeyboardChars(str);
}
- (BOOL)hasText {
return true;
}
- (void)showKeyboard {
dispatch_async(dispatch_get_main_queue(), ^{
INFO_LOG(Log::System, "becomeFirstResponder");
[self becomeFirstResponder];
});
}
- (void)hideKeyboard {
dispatch_async(dispatch_get_main_queue(), ^{
INFO_LOG(Log::System, "resignFirstResponder");
[self resignFirstResponder];
});
}
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (void)buttonDown:(iCadeState)button
{
g_iCadeTracker.ButtonDown(button);
}
- (void)buttonUp:(iCadeState)button
{
g_iCadeTracker.ButtonUp(button);
}
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesBegan(presses, event);
}
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesEnded(presses, event);
}
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(UIPressesEvent *)event {
KeyboardPressesEnded(presses, event);
}
#pragma mark - Status Bar Control
// The immersive mode setting is per-orientation, so we need to know which way we're facing.
// Can't just use g_display for this, since it lags behind during rotation.
// Note: Using viewIfLoaded, since this can get called before the view exists, and we don't want to force it into existence.
- (DeviceOrientation)currentDeviceOrientation {
UIWindowScene *scene = self.viewIfLoaded.window.windowScene;
if (scene != nil) {
return UIInterfaceOrientationIsPortrait(scene.interfaceOrientation) ? DeviceOrientation::Portrait : DeviceOrientation::Landscape;
}
CGSize size = self.viewIfLoaded.bounds.size;
return size.height > size.width ? DeviceOrientation::Portrait : DeviceOrientation::Landscape;
}
// iOS calls this to determine whether to hide the status bar.
// Note that on iPhone, iOS hides it in landscape regardless of what we return here.
- (BOOL)prefersStatusBarHidden {
return g_Config.GetDisplayLayoutConfig([self currentDeviceOrientation]).bImmersiveMode ? YES : NO;
}
// Optional: choose light/dark text for the status bar
- (UIStatusBarStyle)preferredStatusBarStyle {
return UIStatusBarStyleLightContent;
}
- (void)onOrientationChanged {
[self setNeedsStatusBarAppearanceUpdate];
}
// Called from the C++ side when the user toggles the immersive mode setting.
- (void)immersiveModeChanged {
[self setNeedsStatusBarAppearanceUpdate];
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
// The immersive mode setting is per-orientation, so the status bar may need to change along with the rotation.
[self setNeedsStatusBarAppearanceUpdate];
[self.view endEditing:YES]; // clears any input focus
}
// Rotations and window resizes land here. UIKit lays out at the final size at the start of a
// rotation animation, so resizing here (rather than when the rotation completes) keeps the
// backbuffer matching the view while it animates.
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGSize size = self.view.bounds.size;
if (size.width <= 0.0 || size.height <= 0.0 || CGSizeEqualToSize(size, lastLayoutSize_)) {
return;
}
lastLayoutSize_ = size;
INFO_LOG(Log::G3D, "Laid out at %dx%d", (int)size.width, (int)size.height);
// Resized in place: NativeFrame picks up the new size and lets the graphics context resize.
[self updateResolutionWithView:self.view];
}
// Pixels per point. nativeScale, not scale, since that's what the drawable is - they differ on
// some phones, like the Plus models and the minis.
- (CGFloat)displayScaleForView:(UIView *)view {
if (view.window.windowScene) {
return view.window.windowScene.screen.nativeScale;
}
return [UITraitCollection currentTraitCollection].displayScale;
}
- (void)updateResolutionWithView:(UIView *)view {
CGFloat scale = [self displayScaleForView:view];
// The bounds are already corrected for orientation.
CGSize size = view.bounds.size;
float dpi = (IS_IPAD() ? 200.0f : 150.0f) * scale;
const float dpi_scale_x = 240.0f / dpi;
const float dpi_scale_y = 240.0f / dpi;
g_display.Recalculate(size.width * scale, size.height * scale,
dpi_scale_x, dpi_scale_y,
UIScaleFactorToMultiplier(g_Config.iUIScaleFactor));
[view setContentScaleFactor:scale];
// NativeFrame updates the PSP_CoreParameter() size along with the rest.
NativeResized();
NSLog(@"Updated display resolution: (%d, %d) @%.1fx",
g_display.pixel_xres, g_display.pixel_yres, (float)scale);
}
@end