The framework runs on Android — and only Android. Every screen object uses @AndroidFindBy, which tells Appium's PageFactory to look for Android locators and ignore everything else. Try to run against an iOS simulator and AppiumFieldDecorator finds no recognized annotations, every field stays null, and your first test fails the moment it touches an element.
AppiumFieldDecorator supports two locator annotations on the same field — one for Android, one for iOS — and picks the right one at runtime based on the driver type. You keep LoginScreen as a single class. You keep the step definitions exactly as they are. You change two lines in application.properties and run on iOS.
Add one iOS annotation per field. That's the full scope of the change.
💡 This is Article 5 of the Framework Series. It picks up directly from BDD and Cucumber with Appium: A Practical Integration Guide. TheDriverManager,BasePage, Cucumber wiring, and step definitions from Articles 3 and 4 are all in place — this article extends the screen objects, nothing else.
How Dual Annotations Work
When AppiumFieldDecorator initializes a screen object, it reads the annotations on each field and decides which locator to use based on the driver type in the current session:
- If the session is an
AndroidDriver, it reads@AndroidFindByand ignores@iOSXCUITFindBy. - If the session is an
IOSDriver, it reads@iOSXCUITFindByand ignores@AndroidFindBy.
This means you can declare both on the same field:
@AndroidFindBy(accessibility = "input-email")
@iOSXCUITFindBy(accessibility = "input-email")
private WebElement emailField;At runtime, only the annotation that matches the active driver is used. The other is silently skipped. One class, both platforms.
💡 This is DRY from the framework blueprint: one screen object per screen, one place to update when the UI changes. There's no Android copy and iOS copy to keep in sync — the two locators live side by side in a single class.
What You're Building
This article makes two changes to the framework:
- Update
HomeScreen— add@iOSXCUITFindByalongside each existing@AndroidFindBy - Update
LoginScreen— same treatment
The step definitions, the feature file, the Cucumber wiring, the DriverManager — none of it changes. DriverManager already creates an IOSDriver when test.platform=ios; AppiumFieldDecorator handles the rest automatically.
Step 1: Update HomeScreen
Open team-tests/src/main/java/com/mobileframework/screens/HomeScreen.java and add @iOSXCUITFindBy alongside each existing Android annotation:
package com.mobileframework.screens;
import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;
@Component
public class HomeScreen extends BasePage {
@AndroidFindBy(accessibility = "Home-screen")
@iOSXCUITFindBy(accessibility = "Home")
private WebElement screenContainer;
@AndroidFindBy(accessibility = "Login")
@iOSXCUITFindBy(accessibility = "Login")
private WebElement loginMenuItem;
public HomeScreen(DriverManager driverManager) {
super(driverManager);
}
public boolean isLoaded() {
return screenContainer.isDisplayed();
}
public void tapLogin() {
loginMenuItem.click();
}
}The only changes are the two new import lines and the @iOSXCUITFindBy annotations. The class name, constructor, and methods are untouched.
Step 2: Update LoginScreen
Open team-tests/src/main/java/com/mobileframework/screens/LoginScreen.java and add the iOS annotations:
package com.mobileframework.screens;
import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;
@Component
public class LoginScreen extends BasePage {
@AndroidFindBy(accessibility = "input-email")
@iOSXCUITFindBy(accessibility = "input-email")
private WebElement emailField;
@AndroidFindBy(accessibility = "input-password")
@iOSXCUITFindBy(accessibility = "input-password")
private WebElement passwordField;
@AndroidFindBy(accessibility = "button-LOGIN")
@iOSXCUITFindBy(accessibility = "button-LOGIN")
private WebElement loginButton;
@AndroidFindBy(id = "android:id/message")
@iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeAlert' AND name == 'Success'")
private WebElement alertMessage;
public LoginScreen(DriverManager driverManager) {
super(driverManager);
}
public boolean isLoaded() {
return emailField.isDisplayed();
}
public void login(String email, String password) {
emailField.sendKeys(email);
passwordField.sendKeys(password);
loginButton.click();
}
public boolean isLoginSuccessful() {
return alertMessage.isDisplayed();
}
}Most fields use the same accessibility identifier on both platforms — "input-email", "input-password", "button-LOGIN". The alertMessage field is the exception: Android uses a system alert dialog located by resource ID (android:id/message), while iOS uses a native XCUIElementTypeAlert located by NSPredicate (type == 'XCUIElementTypeAlert' AND name == 'Success'). The two annotations on that field show exactly where the platforms diverge.
Step 3: Run on Android
No step definition or feature file changes are needed. Make sure application.properties targets Android:
test.platform=android
device.udid=emulator-5554
app.path=/path/to/android.wdio.native.app.v2.0.0.apk
app.package=com.wdiodemoapp
app.activity=com.wdiodemoapp.MainActivityThen run from the project root:
mvn clean testDriverManager creates an AndroidDriver. AppiumFieldDecorator reads @AndroidFindBy on every field and skips @iOSXCUITFindBy. The scenario runs identically to Article 4.
Step 4: Run on iOS
Update application.properties to target iOS:
test.platform=ios
device.udid=<your-simulator-udid>
app.path=/path/to/iOS.Simulator.NativeDemoApp.v2.0.0.appTo find your simulator UDID, run xcrun simctl list devices in a terminal. Remove app.package and app.activity — those properties have empty string defaults and are silently ignored when the iOS branch in DriverManager runs.
💡 Running on a real device instead of a simulator? Use the device UDID fromidevice_id -l(requires libimobiledevice) or find it in Xcode under Window > Devices and Simulators. Replaceapp.pathwith the path to your.ipafile — real devices use.ipa, simulators use the.appbundle.
💡 Make sure your simulator is booted before running. Runxcrun simctl boot <your-simulator-udid>thenopen -a Simulatorif it isn't already running — Appium will fail to create the session against a shutdown simulator.
Then:
mvn clean testDriverManager creates an IOSDriver. AppiumFieldDecorator reads @iOSXCUITFindBy on every field and skips @AndroidFindBy. The same scenario runs, the same step definitions execute, the feature file hasn't changed.
💡 Switching platforms from the command line. Instead of editing application.properties, pass properties as Maven system properties: mvn clean test -Dtest.platform=ios -Ddevice.udid=<ios-udid> -Dapp.path=<ios-app-path>. You still need to supply the correct device.udid and app.path for the target platform — test.platform alone isn't enough. The key insight is that application.properties keeps your Android values as the default; the CLI properties override only what you pass, so you're not replacing the file — you're patching it at runtime. This is useful when running both platforms in CI without maintaining separate properties files. Article 6 (Configuration Management) takes this further with Spring profiles and externalized configuration.
🚨 Common failure points at this step:
- Elements not found on iOS — locator mismatch. Connect Appium Inspector to the iOS simulator and confirm the identifiers match what's in your version of the app. The
alertMessagelocator usesiOSNsPredicate = "type == 'XCUIElementTypeAlert' AND name == 'Success'"— if your build shows a different alert title, update thenamevalue in the predicate accordingly. DriverManagercreates anIOSDriverbut the XCUITest session fails — the most common cause is a mismatch betweendevice.udidandapp.path. Make sure both point to the same target: simulator UDID with a.appbundle, or real device UDID with an.ipa. See the setup steps above for how to find each.- Both
@AndroidFindByand@iOSXCUITFindByseem to be ignored —AppiumFieldDecoratorisn't initialized. This meansBasePage's constructor didn't run. Check that your screen object callssuper(driverManager)and thatDriverManageris injected correctly.
Understanding What You've Built
The change across this article was minimal by design:
| Component | Change | Why |
|---|---|---|
HomeScreen |
Added @iOSXCUITFindBy to each field |
AppiumFieldDecorator uses the annotation that matches the active driver |
LoginScreen |
Added @iOSXCUITFindBy to each field |
Same — alertMessage shows where platforms genuinely diverge |
LoginScreenSteps |
None | Step definitions call screen object methods; they don't touch locators |
login.feature |
None | The scenario describes behavior, not implementation |
application.properties |
test.platform, device.udid, app.path |
Controls which driver DriverManager creates — the only runtime switch |
DriverManager |
None | Already handles both platforms since Article 3 |
💡 The dual-annotation approach works well as long as the two platforms share the same interaction logic and only the locators differ. When a screen needs genuinely different method behavior per platform — different gestures, elements that exist on one platform but not the other — the right move is to extract a shared interface and write separate implementations. That design is covered later in the series when the framework is tested against more complex screens.
What's Next?
The next article — Configuration Management in Depth: Environments, Device Profiles, and Test Profiles — takes application.properties and replaces it with a proper configuration system: Spring profiles for environments, device profiles for different hardware, and test profiles for different run configurations. That one-line test.platform switch you're making manually today becomes something you can drive entirely from your CI pipeline without editing a single file.
Each article picks up exactly where the last one left off. If mvn clean test passed on both Android and iOS, the cross-platform foundation is in place.
Download the Code
The complete, runnable workspace for this article is available as a free download — everything built across Articles 2, 3, 4, and 5, wired together and ready to open in your IDE.
What's inside:
- The full Maven multi-module project — root POM,
coremodule,team-testsmodule DriverManagerandBasePagefrom Article 3- Cucumber wiring from Article 4 —
RunCucumberTest,CucumberSpringConfiguration,login.feature,junit-platform.properties HomeScreenandLoginScreen— both updated with dual@AndroidFindBy/@iOSXCUITFindByannotationsLoginScreenSteps— unchanged from Article 4application.propertiestemplate — updatetest.platform,device.udid, andapp.pathbefore running
💡 Before running, open application.properties and set test.platform, device.udid, and app.path to match your machine and the platform you want to test first. Then run mvn clean test from the project root — Steps 3 and 4 above cover the full prerequisite checklist.
✅ Subscribe below to download the workspace .zip — free, just your email, and you'll get every new Framework Series article as it's published.