Apple has strict rules for installing apps on real devices. Basically, everyone should download apps from the App Store so that they can get commissions from developers. A valid IPA(iOS App Store package) which can run on devices should contain a valid certification authenticated by Apple. The certification is generated from Apple and signed with an Apple ID.
To create a valid IPA, we need an Apple ID. By logging in to the account using Xcode, the source code can be compiled along with the certification downloaded from Apple. Once this is done, the API can be authorized by Apple and installed on devices.
-
Devices
- Mac
- iPad
-
Software
- Apple ID
- Xcode
- macOS Ventura 13.5 or higher version: Xcode v15
- Before macOS Ventura 13.5: Xcode v14.3.1.zip
- Minimum requirements and supported SDKs: https://developer.apple.com/support/xcode/
- All Xcode downloads page: https://developer.apple.com/download/all/?q=xcode
-
Source code
The given instruction is for using Xcode v15.0.
Step 1. Log in with your Apple ID on Xcode
Open Xcode, press Command
+ ,
to open Xcode Settings. Setting > Accounts > โ

Choose Apple ID

Then log in with your Apple ID.
Your account should appear in the list after successfully logging in with your Apple ID.

Step 2. Download the source code at https://github.com/musicabbage/interaction-experiment. The latest version is v1.1
Step 3. Setup signing
Select the project file > Choose InteractExperiment
in the TARGETS list, > Select Signing & Capabilities
> Expand the Signing
section

Check Automatically manage signing
โ
and select the team related to the Apple ID you just logged in to.

If everything is set, you should see the Signing
setting like this:

Trouble shooting:

If you see the error in the Signing Certificate
field with the following message:
Failed to register bundle identifier
The app identifier "ac.sussex.InteractExperiment" cannot be registered to your development team because it is not available. Change your bundle identifier to a unique string to try again.
Which means others used this ID. (The Bundle Identifier must be unique across ALL app IDs worldwide.) Therefore, change the Bundle Identifier
to another unique id, ex: ac.{your_sussex_id}.InteractExperiment.
Step 4. Ensure iOS SDK is downloaded.

Step1. Enable Developer Mode. Open your iPad Settings.
Select Privacy & Security
. In the SECURITY sections, tap Developer Mode
.
Switch on Developer Mode
You will receive a warning that enabling Developer Mode will decrease your device's security and ask you to restart the iPad. Tap the Restart button to continue to open Developer Mode.
You can find Apple's official documentation on enabling Developer Mode with the following link: https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device.
Step 2. Ensure the iPad is connected to the internet.
Step 3. Connect the iPad to the Mac. If it connects successfully, the device will show in the list of running target devices.

Trouble shooting:
If you can't see the iPad on the device list, please check:
- Ensure the iPad is connected to the Mac by checking Finder's Sidebar. (The iPad would show in the Sidebar if it connect to the Mac correctly.) https://support.apple.com/en-gb/guide/mac-help/mchld88ac7da/mac
- Ensure the iOS SDK is Downloaded.
- Ensure Xcode supports the iOS version. https://developer.apple.com/support/xcode
Step 1. [Xcode] Select the running scheme InteractExperiment
.

Step 2. [Xcode] Select the target device.

Step 3. [Xcode] Tap the run button in Xcode.

Step 4. [iPad] Trust the profile for the app on the iPad.
You need to trust the developer profile if you receive an Untrusted Developer
alert on your iPad.
Open iPad Settings > General > VPN & Device Management
.
In the DEVELOPER APP
section, tap Apple Development: {your_Apple_ID}
.
Trust Apple Development: {your_Apple_ID}
.
Step 5. [Xcode] Run the app again. (as the same with Step 3)
That's it!!! ๐ I hope that the app runs smoothly on your iPad.โค๏ธ
- Running your app in Simulator or on a device: https://developer.apple.com/documentation/xcode/running-your-app-in-simulator-or-on-a-device
Such as loading / load text or images / display error message
To make the view model can be reuse between SwiftUI and UIKit
Coordinators are responsible for routing between different views, such as pushing a view to the navigation stack or presenting a modal view or a cover view. There are three things that coordinators have to do:
- Create views.
- Perform push/present behaviours.
- Maintain the state of the navigation stack.
Each view should be clean and only present UI and data. The business logic is stored in the ViewModel, and the routing is performed by coordinators.
To separate the UI and the business logic, a view model is initialized by a coordinator and then injected into the created views. Additionally, the FlowState is used to bind user actions from the UI to the coordinator.
struct CreateConfigurationCoordinator: View {
@StateObject var state: CreateConfigFlowState
private let viewModel: CreateConfigurationViewModel = .init()
var body: some View {
CreateConfigurationView(flowState: state, viewModel: viewModel)
}
}
To route to this coordinator, just create this coordinator like this:
var body: some View {
CreateConfigurationCoordinator()
}
Push and present behaviours are triggered by a FlowState
, such as:
class CreateConfigFlowState: ObservableObject {
// 1
@Binding var path: NavigationPath
// 2
@Published var presentedItem: CreateConfigFlowLink?
@Published var coverItem: CreateConfigFlowLink?
init(path: Binding<NavigationPath>) {
_path = .init(projectedValue: path)
}
// 3
static var mock: CreateConfigFlowState {
CreateConfigFlowState(path: .constant(.init()))
}
}
// 4 & 5
enum CreateConfigFlowLink: Hashable & Identifiable {
case FlowLinkA
case FlowLinkB(String) //parameter
var id: String { String(describing: self) }
}
- If this coordinator might be contained in a navigation stack, it should have a NavigationPath with the
@Binding
property wrapper. This NavigationPath needs to be injected from its parent coordinator so that the NavigationView which store in the parent can be notified when the path is changed.
// parent coordinator
NavigationStack(path: $state.path) {
//views
}
-
presentedItem
andcoverItem
represent modal and full-screen views, respectively. These are represented as an enum so that when the coordinator is notified of presenting a view, it can identify which view will be shown based on the enum value. -
You can create a
mock
coordinator variable for testing and SwiftUI preview. -
Declare an enum for FlowLink, which defines different routes based on users' actions. In this case, there are two actions
FlowLinkA
andFlowLinkB
. You can define paremeters by using associated values. -
FlowLink conforms to
Hashable
andIdentifiable
protocols to enable its usage in NavigationPath.
With a FlowState, a coordinator can perform a push/present action with the following code:
struct CreateConfigurationCoordinator: View {
NavigationStack(path: $state.path) {
CreateConfigurationView(flowState: state, viewModel: viewModel)
.navigationDestination(for: CreateConfigFlowLink.self, destination: navigationDestination)
.sheet(item: $state.presentedItem, content: presentContent)
.fullScreenCover(item: $state.coverItem, content: coverContent)
}
}
private extension CreateConfigurationCoordinator {
@ViewBuilder
private func navigationDestination(link: CreateConfigFlowLink) -> some View {
switch link {
case let .FlowLinkB(parameter):
//some view
default:
Text("not implemented process")
}
}
@ViewBuilder
private func presentContent(item: CreateConfigFlowLink) -> some View {
switch item {
case .FlowLinkA:
//some view
default:
Text("undefined present content")
}
}
@ViewBuilder
private func coverContent(item: CreateConfigFlowLink) -> some View {
//some view
}
}
The NavigationPath instance is initialised by the coordinator who owns the NavigationStack. It passes the path into child coordinator to make children can push/pop views.
- Parent
@ObservedObject var state: RootFlowState = .init()
var body: some View {
NavigationStack(path: $state.path) {
RootView(flowState: state)
.navigationDestination(for: RootFlowLink.self, destination: navigationDestination)
}
- Children
class ChildFlowState: ObservableObject {
@Binding var path: NavigationPath
init(path: Binding<NavigationPath>) {
_path = .init(projectedValue: path)
}
}
struct ChildCoordinator: View {
@StateObject var state: ChildFlowState
init(navigationPath: Binding<NavigationPath>) {
_state = .init(wrappedValue: .init(path: navigationPath))
}
}
To push a new view, child coordinator can append a new FlowLink into the NavigationPath:
state.path.append(ChildFlowLink.someLink)
Put them together, the architecture with coordinators can be described by the following diagram:
I used these articles as references to construct this coordinator structure. ๐
{Name} (Trial {trial number}){experiment start date}
ex. Anonymous Participant (Trial 1) 2023-07-04 - 18-48-10.txt
outputFileString = InteractLog.filepath+" (Trial "+trial.getTrialnr()+") "+filedate.format(expStartDate)+".txt";
Name :Anonymous Participant
Experiment Start :04/07/2023 - 17:29:30
Stroke Colour :0,0,0,255
Background Colour :255,255,255,255
Stroke Width :2.0
Stimulus Files :S1.png,S2.png,S3.png,S4.png,S5.png,S6.png,S7.png,S8.png
Familiarisation File :P1.png
Input Mask File :
Drawing Pad Size :1260,600
Trial Number :1
Trial Start :04/07/2023 - 17:29:34
Trial End :04/07/2023 - 17:29:41
Field | |
---|---|
Name | |
Experiment Start | After inputting participant id (instruction) |
Stroke Colour | |
Background Colour | |
Stroke Width | |
Stimulus Files | |
Familiarisation File | |
Input Mask File | |
Drawing Pad Size | |
Trial Number | |
Trial Start | Experiment startNextTrial (InputPad init) |
Trial End | KeyEvent.VK_ESCAPE (trial ended, before end message) |
Code | Callstack |
---|---|
("0;0","KeyPressed_"+kc+"("+InteractLog.data_separator_descr+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"(RETURN"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+InteractLog.repstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+InteractLog.nextstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+InteractLog.prevstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+InteractLog.hidestim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+InteractLog.endexp_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyPressed_"+kc+"("+e.getKeyChar()+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.data_separator_descr+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"(RETURN"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.repstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.nextstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.prevstim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.hidestim_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+InteractLog.endexp_keyname+":"+e.getKeyLocation()+")") |
|
("0;0","KeyReleased_"+kc+"("+e.getKeyChar()+":"+e.getKeyLocation()+")") |
|
eventCount+";"+System.nanoTime()+";"+x2+";"+y2+";ButtonReleased"+newline; |
mouseReleased |
eventCount+";"+System.nanoTime()+";"+x1+";"+y1+";ButtonDown"+newline; |
mousePressed |
("0;0","DrawingEnabled") |
InputPane init |
("0;0","FamiliarisationOff_"+InteractLog.famfilename) |
InputPane removeStim (firststim) |
("0;0","StimulusOff_"+InteractLog.stimfilename) |
InputPane removeStim (stimpresent) |
("0;0","StimulusOn_"+InteractLog.stimfilename) |
InputPane showStim |
("0;0","FamiliarisationOn_"+InteractLog.famfilename) |
StimPanel init |
ButtonDown/ButtonReleased
: strokesData- ButtonDown: get
x1
,y1
- mousePressed
- ButtonReleased: add to strokesData
strokesData+= ";"+temp[1]+";"+x1+";"+y1+";"+temp[2]+";"+temp[3]+newline;
- mouseReleased
- ButtonDown: get
temp = {String[5]@3809} ["6", "24912044833416", "373", "175", "ButtonDown"]
0 = "6"
1 = "24912044833416"
2 = "373" //x1
3 = "175" //y1
4 = "ButtonDown"
["7", "24913597758916", "326", "366", "ButtonReleased"]
KeyReleased
: keyData
InputPad line.170
if(kc == KeyEvent.VK_ESCAPE || (e.isControlDown() && kc == KeyEvent.VK_ENTER)) {
if(writeFile()) ex.endTrial();
}