Sunday, October 21, 2012

Tutorial 22 - Building a Universal App in Codea

Figure 1.

22.1 Going Universal in Xcode


A universal application is defined as one targeted at both the iPhone/iPod touch and iPad. If you want to build a universal iOS application then you have to use the runtime and Xcode. There is currently no way to target iPhone development from within Codea.

This is handy, since we plan to use our old staple Mine Sweeper to demonstrate how to turn your Codea App into one which can run on an iPhone (or iPod Touch for that matter). As we have added Game Center code to MineSweeper, all future development of this game will have to occur in Xcode.

In principle this is a simple three step process:
  1. Target the application to iPhone/iPad in the Xcode deployment build settings;
  2. Add the relevant iPhone graphical assets (icons, default images etc.);
  3. Use a bunch of if (device is an iPhone) then do one thing else do another thing. Step 2 is simplified by Apple providing a new macro called UI_USER_INTERFACE_IDIOM() that will tell you what device your code is currently running on. There are two values defined, UIUserInterfaceIdiomPhone and UIUserInterfaceIdiomPad, and this macro will return the appropriate value based on the current device.
It would be a very short tutorial if that was all there was to it. If your App has any sort of complexity then there are a few things that you need to watch out for.

22.2 Step 1: Targeting iPhone and iPad in Xcode


As with the previous few tutorials, we are assuming that you have already got your Codea App up and humming in the Xcode iPad simulator using the runtime. If you haven't got to this state then go back and have a look at Tutorial 12.

Targeting your App for iPhone and iPad is easy one you have found the setting. Open up Xcode with your App. You need to have the Project Navigator open (top left hand tool bar item - it looks like a file folder). Click on the item at the very top of the Navigator called "Codea Template."

When you create a new project, it includes one or more targets, where each target specifies one build product and the instructions for how that product is to be built. You can use the project editor to specify every aspect of the build, from the version of the SDK to specific compiler options.

In the next column over you will see two items, Project (CodeaTemplate) and Targets (MineSweeper - in our case, this will be the name of your App). 

Selecting the project file opens the project settings editor. The project settings editor is Xcode 4′s replacement for Xcode 3′s project and target inspectors. Select the target from the project settings editor. 

Figure 2. Setting your App to Universal.

To specify the device families on which you want your app to be able to run:

  1. In the project navigator, select the project. 
  2. From the target list in the project editor, select the target that builds your app, and click Summary. 
  3. From the Devices pop-up menu, choose Universal (to target both families - iPhone and iPad are the other two options - see Figure 2.).
And that is it. You should now be able to select either iPhone or iPad from the simulator. It is unlikely that the iPhone version will work but you can give it a shot if you want. MineSweeper certainly crashed and burned. The app should still compile and run ok as an iPad app.

22.3 Step 2: Adding iPhone Graphical Assets


Once you have made your app universal, Xcode will allow you to set up the icon and default images for your iPhone. Before we do this, here is a quick refresher on the screen resolutions that we need to consider. 

iPad:
  • 1024 x 768 px; and
  • 2048 x 1536 px for Retina displays (@2x).
iPhone:
  • 320 x 480 px;
  • 640 x 960 px for Retina displays (@2x); and
  • 640 x 1136 px for the iPhone 5 and iPod touch (5th Generation).
You will also need an icon in the 57 x 57 pixel size and 114 x 114 pixels (@2x for the retina version). These are fairly easy to scale from your iPad icons.

You can also use your  default iPad images for your iPhone but they won't scale exactly (you will need to crop either the width or height) if you want to maintain the aspect ratio.

The easiest way to add the iPhone versions of your icon and default load images is to drag them from Finder onto the deployment information page in Xcode. To bring up this page, select the CodeaTemplate at the top of the left hand Project Navigator pane. Click on Targets (Minesweeper in our case) in the middle pane and Summary in the far right pane (see Figure 3.)

Figure 3. Adding the iPhone Graphical Assets

Once you have the screen shown in Figure 3, you can drag across the appropriate image files from Finder. If necessary, Xcode will rename the files and copy them into your project. You should now be able to run your application in the iPhone simulator and see the default image load and when you exit the app using the home button you will be able to see the appropriate icon. That may be all you need to do, but probably not.

22.4 Step 3: Code Modifications


To get our App running on the iPhone we need to make some changes. What you have to watch out for is any hard coded view sizes. Some of our graphical assets will have to be redone to suit the dimensions of the iPhone screen. Let's start by getting the splash screen to work.

In the CodifyAppDelegate of the runtime the base app window is created when the application finishes launching. The good news is that the following line of code will create a window which will fit either an iPhone or iPad.

self.window = [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]] autorelease];

So there is nothing we need to do from a runtime perspective (unless you are using Game Center - see below). If we did need to modify anything in the Objective C portion of our code we could use the following pattern:

// Check if device is an iPhone or iPad
// We now also need to check whether we have a iPhone 5 
// or 5th Generation iPod touch.
    
    if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)
        NSLog(@"App Delegate - iPad device detected.");
    else if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPhone)
    {
        CGSize result = [[UIScreen mainScreen] bounds].size;
        if (result.height == 480)
        {
            NSLog(@"App Delegate - iPhone/iPod Touch detected (< v5).");
        }
        if (result.height == 568)
        {
            NSLog(@"App Delegate - iPhone/iPod Touch detected (> v5).");
        }
    }

Paste a copy of this in the - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions method of the CodifyAppDelegate.m file if you want to try it out.

Now we do have to modify our Lua splash screen class as it loads background images which are too large for the iPhone screen. To assist with the conditional coding we need to add, we wrote a new helper function for the Lua portion of the code to determine what device we are running on. This was added to the Main tab, so that we know it will be available throughout the rest of the code. Depending on what resolution the forthcoming iPad mini ends up with, we might need to update this in the next couple of weeks. 

-- This function will detect the Device which is in use, 
-- required as our app is now universal

function deviceIsIpad()

    if CurrentOrientation == LANDSCAPE_LEFT 

      or CurrentOrientation == LANDSCAPE_RIGHT then
        if WIDTH == 1024 then
            return true
        else
            return false
        end
    else
        if WIDTH == 768 then
            return true
        else
            return false
        end
    end
    
end

As the device isn't going to change mid program, you can call this function once and assign it to a global boolean if you are worried about performance. Performance isn't an issue for MineSweeper so we wont bother.

Using this function we can now conditionally load the background image depending on the device. In the function SplashScreen:init(splashText, fadeSpeed) we have modified the sprite loading as follows:

    if deviceIsIpad() then
        landscapeImage = readImage("Dropbox:MS_TitleScreen_1024x768")
        portraitImage = readImage("Dropbox:MS_TitleScreen_768x1024")
    else
        landscapeImage = readImage("Dropbox:Default-Landscape~iphone")
        portraitImage = readImage("Dropbox:Default-Portrait~iphone")
    end

Remember that if you need to add new images to your Dropbox folder on the Mac then you will have to do a clean build for Xcode to recognise them. If you don't do this you will get a sprite loading error. To find the folder on your Mac to add the images, right click on the Drop Box sprite pack folder in the Xcode Project Navigator (Frameworks -> Codea -> SpritePacks) and select "Show in Finder". You can then drag across the new images that you want to use.

The other change that we need to make is in the function SplashScreen:draw().

        if deviceIsIpad() then
            sprite(logoImage, WIDTH - logoImage.width - 20, 20)
            text(self.splashText, WIDTH/2, HEIGHT/2 + 250 + portraitTextOffset)
            fill(whiteColor)
            fontSize(20)
            text("Version: "..version, WIDTH/2, HEIGHT/2 + 200 + portraitTextOffset)
        else
            fontSize(42)
            text(self.splashText, WIDTH/2, HEIGHT - 80)
            fill(whiteColor)
            fontSize(14)
            text("Version: "..version, WIDTH/2, 50)
        end

You will probably need to make similar adjustments in font size and composition to suit the smaller iPhone screen We also don't load the Reefwing Software logo if the device is an iPhone due to the space constraints. Make sure that the resulting view looks okay in both landscape and portrait orientations (command right arrow to change the orientation of the simulator in Xcode). As an aside you should find that the Fader class works as expected regardless of the device.


Now that you have the splash screen sorted we can turn our attention to the Menu screen. For us the changes here are minimal for the portrait orientation. We load up a smaller MineSweeper logo to fit on the iPhone screen and once again there is no room for the Reefwing and Codea logos so we dispense with them. The RoundBorder class works on all devices without any changes.

In the Main setup function where we load images we added:

-- Load the MineSweeper Text Logo
    
    if deviceIsIpad() then
        msTextLogo = readImage("Dropbox:minesweeper_logo 400x76px")
    else
        msTextLogo = readImage("Dropbox:minesweeper_logo 200x38px")
    end

And then in drawMenu() we shuffle the button positions and eliminate the "Medium" and "Hard" game options since those grid sizes wont fit on an iPhone screen (see section 22.5 Lessons Learned). The upside of removing the "Medium" and "Hard" buttons is that the remaining three buttons fit easily on the iPhone screen in both portrait and landscape orientations. Since there is only one game difficulty available on the iPhone we also changed the button text from "Easy" to "Start".


The achievement and leader board Game Center functionality works fine on an iPad but due to the way these modal views are displayed, on an iPhone the Lua draw() loop gets stopped if you tap the "Achievements" or "High Score" buttons. This results in the app still running but the screen never gets refreshed. To prevent this from occurring we need to modify the aGameCenter_Codea.m file that we introduced in the previous Game Center tutorial. The fix is to call [[SharedRenderer renderer] startAnimation] when the Game Center view controller is dismissed. This method is in the BasicRendererViewController of the runtime. An example  for dismissing achievements is shown below. The same is required for dismissing leader boards.

- (void)achievementViewControllerDidFinish:(GKAchievementViewController *)viewController
{
    [[SharedRenderer renderer] dismissViewControllerAnimated:YES completion:nil];
    [[SharedRenderer renderer] startAnimation];
}

This isn't a problem on the iPad because the Game Center view doesn't cover the whole screen (which causes the viewWillDisappear method to be called in the rendering code and stops animating the draw loop).

We have two more screens that we need to tweak and then we are done. Firstly the High Score screen which shows the scores stored locally is not particularly useful on the iPhone version (and is somewhat redundant as we are now using Game Center). So in the function scoreButtonPressed() we wont show this screen if we are an iPhone using the following code.

if deviceIsIpad() then
        gameState = stateScore
end

Remember that the gameState determines what the draw() loop displays. By not setting this to stateScore if the device is an iPhone, this screen wont be displayed.


Our last job is to sort out the game playing screen. The grid display is platform agnostic so it is only the button and text placement that we need to worry about. In the function drawCellsLeftDisplay() we do some more conditional placement.

    local iPhoneHeightAdjustment = 0

    if CurrentOrientation == LANDSCAPE_LEFT
      or CurrentOrientation == LANDSCAPE_RIGHT then
        iPhoneHeightAdjustment = 50
    end



    if deviceIsIpad() then
        text(cellsLeft, w + 50, HEIGHT - 100)
        text(math.round(gameTime), WIDTH - 150, HEIGHT - 100)
    else 
        text(cellsLeft, w - 30, HEIGHT - 100 + iPhoneHeightAdjustment)
        text(math.round(gameTime), WIDTH - 70, HEIGHT - 100 + iPhoneHeightAdjustment)
    end

The buttons on the iPhone screen will need to be placed based on the current orientation due to the space constraints. We do this in the drawGameButtons() function.

function drawGameButtons()
    
    -- These are the buttons visible on the game run screen
    -- Adjust the button position based on the device and orientation.
    -- In particular, we need to move the button location
    -- if the current device is an iPhone/iPod touch.
    
    local mButtonSize = vec2(100, 50)
    local mLocX, mLocY = 0, 0

    if CurrentOrientation == LANDSCAPE_LEFT 
      or CurrentOrientation == LANDSCAPE_RIGHT then
        if deviceIsIpad() then
            mLocX = WIDTH - 200
            mLocY = HEIGHT - 195
        else
            mLocX = WIDTH - 200 + 90
            mLocY = HEIGHT - 195 - 100
        end
    else
        if deviceIsIpad() then
            mLocX = WIDTH - 125
            mLocY = HEIGHT - 320
        else
            mLocX = WIDTH - 120
            mLocY = HEIGHT - 320 - 140
        end
    end
    
    flagButton.x, flagButton.y = mLocX, mLocY
    
    if CurrentOrientation == LANDSCAPE_LEFT 
      or CurrentOrientation == LANDSCAPE_RIGHT then
        if deviceIsIpad() then
            mLocY = 110 + mButtonSize.y*2
        else
            mLocY = 110 + mButtonSize.y
        end
    else
        if deviceIsIpad() then
            mLocY = 240 + mButtonSize.y*2
        else
            mLocX = 20
        end
    end
    
    newGameButton.x, newGameButton.y = mLocX, mLocY
    
    if CurrentOrientation == LANDSCAPE_LEFT 
      or CurrentOrientation == LANDSCAPE_RIGHT then
        if deviceIsIpad() then
            mLocY = 110
        else
            mLocY = 90
        end
    else
        if deviceIsIpad() then
            mLocY = 240
        else
            mLocX = WIDTH / 2 - mButtonSize.x/2
            mLocY = HEIGHT - mButtonSize.y - 10
        end
    end    
        
    menuButton.x, menuButton.y = mLocX, mLocY
    
    flagButton:draw()
    newGameButton:draw()
    menuButton:draw()
    
end



22.5 Lessons Learned


Converting to a universal App was easier than we were expecting. The Codea runtime works out of the box without modification so it is only your Lua code and graphical assets which need to be modified.

For us the most important lesson is that if you want you App to eventually be universal then design it to work on an iPhone/iPod sized screen (320 x 480 pixels) first. It is much easier to convert this to work on a larger screen than vice versa.

The other lesson is to avoid using hard coded co-ordinates if possible and use positions relative to the WIDTH and HEIGHT constants. This will make conversion between different screen sizes much easier and in many cases wont require any changes to your code at all.

Finally if your app includes Game Center functionality then you need to modify the methods which dismiss the Game Center view controllers to restart the Lua draw() loop.



22.6 Download the Code


You can download the updated code below. For simplicity we have provided all the classes even if they haven't changed from a previous version. You should be able to tell from the version number whether you need to download the code or can use a previous version (v1.6 indicates code that was modified for this tutorial).

Lua Code:
  1. Main v1.6
  2. Cell v1.5
  3. Colors v1.1
  4. Physics v1.0
  5. PhysicsDebugDraw v1.0
  6. RoundBorder v1.2
  7. ScoreScreen v1.0
  8. SplashScreen v1.6
  9. Button v1.3
  10. Fader v1.1
  11. IconImages v1.1
  12. TextBox v1.0
  13. Twinkle v1.1
Runtime (Objective C) Code:

3 comments:

  1. this post is very useful ,nice........ but i have some problems in fixing size of viewcontroller.xib file

    ReplyDelete
  2. Viewcontroller alignment is not good

    ReplyDelete