Introduction
In the previous
3 part series, you saw how to create a tile based game for the Universal Windows Platform (UWP).
In this article you look at porting the game to both iOS and Android; creating a cross-platform Xamarin solution in Visual Studio.
You see how nearly all code is shared across platforms. You observe some platform difference, such as those encountered when defining and referencing image resources, and at implementing audio playback for each platform. You look at abstracting platform these differences, providing platform specific implementations at runtime.
You see how to work with XAML in Xamarin forms. You explore rudimentary views, such as text boxes, labels and buttons. You also look at more advanced topics, such as creating a slide-in menu and defining reusable XAML resources. You also see how to use an AbsoluteLayout
to layout the game grid, and at utilizing the game grid's available space.
You also explore touch gestures, and we introduce a new double tap move which causes the Sokoban character to push a treasure across multiple floor spaces.
Know Your Platforms
Xamarin Forms has come a long way over the last couple of years. It’s becoming an increasingly viable candidate for cross-platform development. Xamarin Forms is not a lookless GUI technology. Controls render as they would if implemented natively. Creating a rich multi-faceted app requires an understanding of the underlying platform. There are nuances to each platform that often require tweaking to get the desired look and feel. That’s why, if you’re considering creating a serious app using the Xamarin tooling, I recommend some preliminary reading on the underlying platform or platforms you’re targeting. Platform specific quirks invariably cannot be abstracted. That’s why a good knowledge of the underlying platform is essential to creating rich user interfaces. So, I encourage you to ground yourself in a reasonable knowledge of Android and iOS development before embarking on a complex Forms app.
With that said, you can still have fun creating a relatively simple app in Xamarin Forms without any prior platform specific knowledge. Let’s begin.
Creating a Xamarin Cross-Platform Solution
When creating a new Xamarin solution in Visual Studio there are several options to choose from in the New Project dialog. See Figure 1.
I’m a rather fond of XAML, so for this project I chose the Blank XAML App option. I also chose a PCL project type rather than the Shared project type because I knew that I wanted to constrain all platform specific code to each of the respective platform specific projects, as opposed to relying on preprocessor directives or some other mechanism to include or exclude code. One other reason I chose PCL over the Shared project type is that I have found intellisense to sometimes misbehave when using Shared projects.
Figure 1. Creating a Blank XAML App using the New Project Dialog
Understanding the Xamarin Forms Solution Structure
When you create a Xamarin Forms XAML project, four projects are created:
- An Xamarin Android project
- An Xamarin iOS project
- A UWP project
- And a Xamarin Forms project
In the downloadable solution I’ve grouped the three platform specific projects together. See Figure 2.
The platform specific projects for Android, iOS , and UWP; are entry point applications. While the Xamarin Forms project contains the majority of our GUI code and is effectively hosted within each of the platform specific projects.
With the new Xamarin Forms solution in place we can bring in the Sokoban game PCL and build out the Forms project.
Figure 2. Forms Sokoban Solution
The Sokoban PCL project (Sokoban.csproj) is platform agnostic, and is a carry over from the previous article series. To implement the game for Xamarin Forms we need to build out the Forms project (Outcoder.Sokoban.Launcher) and implement the infrastructure classes for each supported platform. The Forms launcher project contains the Xamarin Forms items that are used by each of the Android, iOS and UWP projects.
Constructing the Game UI in Xamarin Forms
The interface for the game consists of the following three sections:
- a top toolbar section,
- a lower section for the game tiles,
- and a slide in menu.
Let’s start by taking a look at the slide-in menu.
Implementing a Slide-In Menu in Xamarin Forms
To achieve the same slide-out menu that I created for the UWP version of the app I use a MasterDetailPage
. The MainPage.xaml file in the Outcoder.Sokoban.Launcher project inherits not from Page
, but from MasterDetailPage
. A MasterDetailPage
allows you to split a page into two parts: the master part, which can be thought of as a set of items that, when selected, change the content in the detail part. The detail part displays one or more different pages representing the selected item in the master page.
Our use of the MasterDetailPage
doesn’t quite fit that description; we use the master section as a menu, and the detail page as the tiled game area. In this app, the detail page doesn’t change.
The root element of MainPage.xaml looks like this:
<MasterDetailPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Outcoder.Sokoban.Launcher.MainPage">
…
When using a Xamarin Forms MasterDetailPage
, there are two main elements you need to build out. The first corresponds to the MasterDetailPage.Master
property, which is the slide in part. The second corresponds to the MasterDetailPage.Detail property
, which, in this case, is the fixed view of the game. Both are populated with a Page
instance; generally a single Page
for the Master
and one or more pages for the Detail
property.
NOTE: Don’t expect the MasterDetailPage
to behave the same on every device. The behaviour of the Master
part may vary according to platform and screen size. If the screen size is substantial enough, then the Master
part may remain permanently in view.
In the Sokoban game, the Master
contains a StackLayout
and several Label
views that are bound to commands in the Game
class. See Listing 1. The Game
class is effectively the ViewModel of the MainPage
.
NOTE: In UWP and WPF the term control is used frequently to denote interactive UI elements. In Xamarin Forms, however, the term view is used. This is because in Xamarin Forms UI elements derive from a base View
class. If you’re coming from the Android development world you’ll feel at home with this nomenclature.
The base View
class contains a GestureRecognizers
property, which can be populated with a set of IGestureRecognizers
. As well as a Tapped
event, the TapGestureRecognizer
has a convenient Command
property that we attach to each of the Games commands.
I make use of both the Tapped
event and the Command
property. The Tapped
event is used to trigger the closing of the menu. I’m not pleased with that, and I would have prefered if the command took care of setting a property to close the menu. But, this was simpler.
Listing 1. MainPage.xaml MasterDetailPage.Master excerpt
<MasterDetailPage.Master>
<ContentPage Title="Menu" BackgroundColor="{StaticResource ChromePrimaryColor}">
<StackLayout Padding="12">
<Label Text="Undo" Style="{StaticResource MenuItemTextStyle}">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding UndoCommand}"
Tapped="HandleMenuItemTapped" />
</Label.GestureRecognizers>
</Label>
<Label Text="Redo" Style="{StaticResource MenuItemTextStyle}">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding RedoCommand}"
Tapped="HandleMenuItemTapped" />
</Label.GestureRecognizers>
</Label>
<Label Text="Restart Level" Style="{StaticResource MenuItemTextStyle}">
<Label.GestureRecognizers>
<TapGestureRecognizer Command="{Binding RestartLevelCommand}"
Tapped="HandleMenuItemTapped" />
</Label.GestureRecognizers>
</Label>
</StackLayout>
</ContentPage>
</MasterDetailPage.Master>
The Tapped
handler for each menu item in the master page calls the CloseMenu
method of the MainPage
, which, in turn, sets the Page’s
built-in IsPresented
property to false. See Listing 2. When IsPresented
is true, the menu expands. When false, it collapses.
Listing 2. MainPage HandleMenuItemTapped and CloseMenu methods
void HandleMenuItemTapped(object sender, EventArgs e)
{
CloseMenu();
}
void CloseMenu()
{
IsPresented = false;
}
Just like UWP and WPF, Xamarin Forms supports a StaticResource
markup extension, which gives you a simple way to share resources across your XAML based app. The resources for the game are located in the App.xaml file in the Outcoder.Sokoban.Launcher project. See Listing 3.
Many of the structures present in Xamarin Forms resemble, or in some cases, match those in UWP or WPF. Here we see that the Application.Resources
are populated with a ResourceDictionary
with various colors used throughout the app.
Listing 3. App.xaml
<Application xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Outcoder.Sokoban.Launcher.App">
<Application.Resources>
<ResourceDictionary>
<Color x:Key="ChromePrimaryColor">#ff9a00</Color>
<Color x:Key="ChromeSecondaryColor">#ee9000</Color>
<Color x:Key="GameShadeColor">#55111111</Color>
<Color x:Key="GameBackgroundColor">#303030</Color>
</ResourceDictionary>
</Application.Resources>
</Application>
The main game UI is defined in the Detail
section of the MasterDetailPage
in MainPage.xaml. See Listing 4.
Just like our UWP implementation in a previous article, the game UI is divided into a top section which shows the level number and so forth, and a bottom section which hosts the game tiles.
An Entry
in Xamarin Forms parlance is a text box, where the user can enter text. The levelCodeTextBox
is an Entry
view that allows the user to jump to a different level if the user knows the correct code corresponding to that level. levelCodeTextBox
retains the same name that I used in the UWP implementation, though it should probably be renamed levelCodeEntry or something like that.
The game grid is implemented using an AbsoluteLayout
view. AbsoluteLayout
is analogous to a UWP or WPF Canvas
control and allows you to position items using X and Y coordinates.
The final element within the detail page is an overlay, which we use to obscure the game to display messages to the user upon level completion. The visibility of the overlay is bound to the FeedbackVisible
property of the game object.
Listing 4. MainPage.xaml MasterDetailPage.Detail excerpt
<MasterDetailPage.Detail>
<ContentPage Title="Game"
BackgroundColor="{StaticResource GameBackgroundColor}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid x:Name="topContentGrid" BackgroundColor="{StaticResource ChromePrimaryColor}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid HorizontalOptions="Start" Padding="12,0,0,0"
WidthRequest="30" HeightRequest="30">
<Image Source="MenuButton.png" Aspect="AspectFit"
HorizontalOptions="Fill" VerticalOptions="Fill">
<Image.GestureRecognizers>
<TapGestureRecognizer Tapped="HandleMenuButtonTapped" />
</Image.GestureRecognizers>
</Image>
</Grid>
<StackLayout Orientation="Horizontal" Grid.Column="1" Padding="12,0,0,0">
<Label Text="Level" Style="{StaticResource LabelStyle}" />
<Label Text="{Binding Level.LevelNumber, Mode=OneWay}"
WidthRequest="30" Style="{StaticResource LabelStyle}" />
</StackLayout>
<StackLayout Orientation="Horizontal" Grid.Column="2">
<Label Text="{Binding Level.Actor.MoveCount, Mode=OneWay}"
Style="{StaticResource LabelStyle}" />
<Label Text="Moves" Style="{StaticResource LabelStyle}" />
</StackLayout>
<StackLayout Orientation="Horizontal" Grid.Column="3" Padding="12,0,12,0">
<Label Text="Code" Style="{StaticResource LabelStyle}" />
<Entry x:Name="levelCodeTextBox" Style="{StaticResource LevelCodeEntryStyle}"
Focused="HandleLevelEntryFocused"
Unfocused="HandleLevelEntryUnfocused"
Completed="HandleLevelEntryCompleted"
BackgroundColor="Transparent" />
</StackLayout>
</Grid>
<AbsoluteLayout x:Name="gameLayout" Grid.Row="1"
BackgroundColor="Transparent" VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" />
<ContentView x:Name="overlayView" HorizontalOptions="Fill" VerticalOptions="Fill"
Grid.RowSpan="2"
BackgroundColor="{StaticResource GameShadeColor}"
IsVisible="{Binding FeedbackVisible, Mode=OneWay}">
<Label
Text="{Binding FeedbackMessage, Mode=OneWay}"
FontSize="Large"
TextColor="White"
IsVisible="{Binding ContinuePromptVisible, Mode=OneWay}"
VerticalTextAlignment="Center"
HorizontalTextAlignment="Center" />
</ContentView>
</Grid>
</ContentPage>
</MasterDetailPage.Detail>
Implementing the Platform Specific Infrastructure
In the previous UWP article series, we needed a way for the Sokoban Game
object to leverage some platform specific functionality. We did this by abstracting that code for each platform via a custom IInfrastructure
implementation.
In the Xamarin Forms implementation of Sokoban, we have a base implementation of the IInfrastructure
interface, named InfrastructureBase
; which is located in the Outcoder.Sokoban.Launcher project. See Listing 5.
The Game
object requires an instance of an implementation of the custom ISynchronizationContext
.
InfrastructureBase
makes use of some of the Xamarin Forms APIs, which are available across all platforms. In particular the Application.Properties
collection is an IDictionary<string, object>
that allows you to persist key value pairs in a platform agnostic way.
Listing 5. InfrastructureBase class.
public abstract class InfrastructureBase : IInfrastructure
{
protected InfrastructureBase(ISynchronizationContext synchronizationContext)
{
SynchronizationContext = ArgumentValidator.AssertNotNull(synchronizationContext,
"synchronizationContext");
}
public abstract int LevelCount { get; }
public abstract Task<string> GetMapAsync(int levelNumber);
public ISynchronizationContext SynchronizationContext { get; }
public virtual void SaveSetting(string key, object setting)
{
Application.Current.Properties[key] = setting;
}
public virtual bool TryGetSetting(string key, out object setting)
{
return Application.Current.Properties.TryGetValue(key, out setting);
}
public virtual T GetSetting<T>(string key, T defaultValue)
{
object result;
if (TryGetSetting(key, out result))
{
return (T)result;
}
return defaultValue;
}
public abstract void PlayAudioClip(AudioClip audioClip);
}
Various IInfrastructure
members, include LevelCount
, GetMapAsync
, and PlayAudioClip
; need to be implemented for each platform. We turn our attention to that in a moment, but first let’s examine the ISynchronizationContext
member.
Abstracting the Thread Synchronization Context
Upon instantiation, InfrastructureBase
requires an object implementing ISynchronizationContext
. You may recall from the previous series that ISynchronizationContext
is used to invoke an action on the main thread. It does this in such a way that if the code is already executing on the UI thread then the action is not pushed onto the UI thread queue but rather executed immediately, which can improve performance.
The Xamarin Forms implementation of ISynchronizationContext
is identical for both Android and iOS and is shown in Listing 6.
On Android and iOS the Mono framework exposes a Thread.CurrentThread.IsBackground
property that enables you to determine if the current thread is the UI thread. UWP lacks such a property and requires a Dispatcher
instance to ascertain that information. Hence the different implementation for UWP. The UWP implementation is described in the previous article series and won’t be covered here.
NOTE: When working with Xamarin Forms you’ll find that the .NET API surface area of iOS and Android is larger than UWP. The reason for this is that iOS and Android are able to leverage the Mono framework, which has a wider set of APIs that encompass much of the .NET FCL (Framework Class Library). I suspect that these differences will gradually disappear over time.
Listing 6. UISynchronizationContext class
class UISynchronizationContext : ISynchronizationContext
{
public bool InvokeRequired => Thread.CurrentThread.IsBackground;
public void InvokeIfRequired(Action action)
{
if (InvokeRequired)
{
Xamarin.Forms.Device.BeginInvokeOnMainThread(action);
}
else
{
action();
}
}
}
The Android and iOS implementations of the IInfrastructure
class are both named Infrastructure
and extend the InfrastructureBase
class. See Listing 7.
In addtition to the ISynchronizationContext
, the Infrastructure
class requires an Activity
instance to retrieve the level map assets. It also requires an AudioClips
object, which is used to play the sound clips during gameplay.
Listing 7. Android Infrastructure class
class Infrastructure : InfrastructureBase
{
const string levelDirectory = "Levels";
readonly List<string> fileList;
readonly Activity activity;
readonly AudioClips mediaClips;
public Infrastructure(
ISynchronizationContext synchronizationContext,
Activity activity,
AudioClips mediaClips)
: base(synchronizationContext)
{
this.activity = ArgumentValidator.AssertNotNull(activity, "activity");
this.mediaClips = ArgumentValidator.AssertNotNull(mediaClips, nameof(mediaClips));
fileList = activity.Assets.List(levelDirectory).Where(name => name.EndsWith(".skbn")).ToList();
}
public override int LevelCount => fileList?.Count ?? 0;
public override Task<string> GetMapAsync(int levelNumber)
{
string fileName = $@"{levelDirectory}/Level{levelNumber:000}.skbn";
using (var stream = activity.Assets.Open(fileName))
{
using (StreamReader reader = new StreamReader(stream))
{
string levelText = reader.ReadToEnd();
return Task.FromResult(levelText);
}
}
}
public override void PlayAudioClip(AudioClip audioClip)
{
mediaClips.Play(audioClip);
}
}
The game’s level files have been linked into the Assets/Levels directory of the Android launcher project (Outcoder.Sokoban.Launcher.Droid). Each level file has its Build Action set to Android Asset.
Android is rather picky where you place resources and arbitrary files. In Xamarin Android, resources such as layout files, images, and audio files must be located in subdirectories of the resources directory. Images used in the game are located in the resources/drawable directory, while MP3 files used in the game are located in the resources/raw directory.
The AudioClips class creates multiple MediaPlayer
objects; one for each sound file. See Listing 8.
The Context
instance, which is passed to the AudioClips
constructor, is used to retrieve each of the .mp3 files located in the Outcoder.Sokoban.Launcher.Droid project’s Resources/raw directory.
For performance reasons, playback is performed on a ThreadPool
thread via a call to the asynchronous Task.Run
in the AudioClips Play
method.
The AudioClip
enum contains values for each of the sound effects and is a cross-platform friendly way of indicating to the AudioClips
class which sound clip to play.
Listing 8. Android AudioClips class
class AudioClips
{
readonly Context context;
const string logTag = "AudioClips";
readonly MediaPlayer levelIntroductionElement;
readonly MediaPlayer gameCompleteElement;
readonly MediaPlayer levelCompleteElement;
readonly MediaPlayer treasurePushElement;
readonly MediaPlayer treasureOnGoalElement;
internal AudioClips(Context context)
{
this.context = ArgumentValidator.AssertNotNull(context, nameof(context));
levelIntroductionElement = CreateElement(Resource.Raw.LevelIntroduction);
gameCompleteElement = CreateElement(Resource.Raw.GameComplete);
levelCompleteElement = CreateElement(Resource.Raw.LevelComplete);
treasurePushElement = CreateElement(Resource.Raw.TreasurePush);
treasureOnGoalElement = CreateElement(Resource.Raw.TreasureOnGoal);
}
MediaPlayer CreateElement(int audioResourceId)
{
var result = MediaPlayer.Create(context, audioResourceId);
return result;
}
void Play(MediaPlayer element)
{
Task.Run(() =>
{
try
{
element.SeekTo(0);
element.Start();
}
catch (Exception ex)
{
Android.Util.Log.Error(logTag,
"Unable to play audio clip.",
Throwable.FromException(ex));
}
});
}
internal void Play(AudioClip audioClip)
{
switch (audioClip)
{
case AudioClip.Footstep:
return;
case AudioClip.GameComplete:
Play(gameCompleteElement);
return;
case AudioClip.LevelComplete:
Play(levelCompleteElement);
return;
case AudioClip.LevelIntroduction:
Play(levelIntroductionElement);
return;
case AudioClip.TreasureOnGoal:
Play(treasureOnGoalElement);
return;
case AudioClip.TreasurePush:
Play(treasurePushElement);
return;
}
}
}
The AudioClips
class is instantiated in the OnCreate
method of the MainActivity
class in the Outcoder.Sokoban.Launcher.Droid project. See Listing 9.
We create an Infrastructure
instance; passing in a UISynchronizationContext
object, the current activity, and the AudioClips
instance.
Listing 9. Android MainActivity.OnCreate method excerpt
protected override void OnCreate(Bundle bundle)
{
...
base.OnCreate(bundle);
global::Xamarin.Forms.Forms.Init(this, bundle);
var audioClips = new AudioClips(this);
var infrastructure = new Infrastructure(new UISynchronizationContext(), this, audioClips);
LoadApplication(new App(infrastructure));
}
LoadApplication
of the Xamarin.Forms.Platform.Android.FormsAppCompatActivity
class takes care of materializing the custom App class. See Listing 10. App extends Xamarin.Forms.Application
, which the base class for any Xamarin Forms App.
The App
object instantiates a MainPage
object, passing it the specified IInfrastructure
instance, which is subsequently passed down to the Sokoban Game
object, allowing it to retrieve maps and play sound effects and so forth.
The Xamarin.Forms.Application
class provides virtual methods for the various application lifecycle events: OnStart
, OnSleep
, and OnResume
.
NOTE: There is no OnEnd
virtual method in the Application
class. One of the reasons is presumably because platforms like Android and UWP don’t offer a reliable way to detect when your app is exiting. That’s why it’s best to assume your app is going to be terminated when OnSleep
is called. When OnSleep
is called, save your app's state.
Listing 10. App.xaml.cs
public partial class App : Application
{
public App(IInfrastructure infrastructure)
{
InitializeComponent();
var mainPage = new MainPage {Infrastructure = infrastructure};
MainPage = mainPage;
}
protected override void OnStart()
{
}
protected override void OnSleep()
{
}
protected override void OnResume()
{
}
}
The MainPage
class constructor subscribes to the page’s Appearing
event. See Listing 11. The Appearing
event allow us to wait until the grid layout has been laid out so that we can reliably ascertain its available size.
The Entry
class (recall they are the text boxes) has a Keyboard
property, which allows you to specify the type of software keyboard and its characteristics. For example, a numeric keyboard contains mostly digits while a URL keyboard will have characters commonly used for entering a web address.
The levelCodeTextBox
’s text is capitalized using the CapitalizeSentence
flag.
NOTE: The Keyboard
property offers a nice abstraction but be mindful that the available keyboard types is the intersection of all keyboard types across all supported platforms. You may find that using a native API gives you access to a keyboard more appropriate to your needs.
Listing 11. MainPage.xaml.cs constructor excerpt
public partial class MainPage : MasterDetailPage
{
bool loaded;
GameState gameState = GameState.Loading;
readonly Dictionary<Cell, CellView> controlDictionary
= new Dictionary<Cell, CellView>();
Game game;
public Game SokobanGame => game;
public IInfrastructure Infrastructure { get; set; }
public MainPage()
{
InitializeComponent();
Appearing += HandleAppearing;
var overlayTapRecognizer = new TapGestureRecognizer();
overlayTapRecognizer.Tapped += HandleOverlayTap;
overlayView.GestureRecognizers.Add(overlayTapRecognizer);
levelCodeTextBox.Keyboard = Keyboard.Create(KeyboardFlags.CapitalizeSentence);
}
…
}
The MainPage
class’s HandleAppearing
method uses a loaded flag to guarantee that its body runs only once.
The Game
object is created using the IInfrastructure
object. The BindingContext
is set to the game object.
NOTE: BindingContext
is analogous to the DataContext
property of FrameworkElements
in UWP and WPF.
The HandleAppearing
method awaits Task.Yield
a couple of times to ensure that the view has been laid out. Then, the StartGame
method is called. See Listing 12.
If the size of the host window changes then the game grid needs to be redrawn. The SizeChanged
event is raised when the orientation of the device is changed, which is handy as it gives us the opportunity to resize the cells.
Listing 12. MainPage HandleAppearing method
async void HandleAppearing(object sender, EventArgs e)
{
if (loaded)
{
return;
}
loaded = true;
game = new Game(Infrastructure);
BindingContext = game;
await Task.Yield();
await Task.Yield();
game.PropertyChanged += HandleGamePropertyChanged;
game.Start();
SizeChanged += HandleWindowSizeChanged;
}
The Game
class implements INotifyPropertyChanged
. We respond to Game
property changes in the HandleGamePropertyChanged
method. See Listing 13.
In this implementation we’re only interested in when the GameState
property changes. When it does, we call the ProcessGameStateChanged
method.
Listing 13. MainPage.HandleGamePropertyChanged method
void HandleGamePropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(Game.GameState):
ProcessGameStateChanged();
break;
}
}
Most of the UI logic has been moved into the Game
class. The only state we’re interested in is the Loading
state, upon which we initialize the level. See Listing 14. The rest of the states offer UI extensibility points. You could potentially start an animation when the level is loading for example.
Listing 14. MainPage. ProcessGameStateChanged method.
void ProcessGameStateChanged()
{
switch (game.GameState)
{
case GameState.Loading:
break;
case GameState.GameOver:
break;
case GameState.Running:
if (gameState == GameState.Loading)
{
InitialiseLevel();
}
break;
case GameState.LevelCompleted:
break;
case GameState.GameCompleted:
break;
}
gameState = game.GameState;
}
Cells that are placed in the game grid are retained in a dictionary. This improves performance when we need to refresh the size of the grid.
Initializing a level involves discarding existing cells and then calling the LayoutLevel
method. See Listing 15.
The levelCodeTextBox
Text
property is set to the game’s LevelCode
property. It’s not directly bound to the game property because we allow the user to attempt to enter a level code. It should really be bound to a game property, with the logic contained with the Game
class, but I didn’t get around to doing that.
Listing 15. InitializeLevel method
void InitialiseLevel()
{
foreach (var control in controlDictionary.Values)
{
DetachCellView(control);
}
controlDictionary.Clear();
gameLayout.Children.Clear();
LayoutLevel();
levelCodeTextBox.Text = game.LevelCode;
}
Populating the game grid involves creating a CellView
for each Cell
object in the game’s Level
. See Listing 16.
We maximize the size of the cells based on the available width and height.
Each CellView
is added to the AbsoluteLayout
view’s Children
collection. We set the position of the CellView
using the AbsoluteLayout
’s static SetLayoutBounds
method.
Listing 16. MainPage LayoutLevel method
void LayoutLevel()
{
Level level = game.Level;
int rowCount = level.RowCount;
int columnCount = level.ColumnCount;
double windowWidth = gameLayout.Width;
double windowHeight = gameLayout.Height;
int cellWidthMax = (int)(windowWidth / columnCount);
int cellHeightMax = (int)(windowHeight / rowCount);
int cellSize = Math.Min(cellWidthMax, cellHeightMax);
int gameHeight = rowCount * cellSize;
int gameWidth = columnCount * cellSize;
int leftStart = (int)((windowWidth - gameWidth) / 2);
int left = leftStart;
int top = (int)((windowHeight - gameHeight) / 2);
for (int row = 0; row < rowCount; row++)
{
for (int column = 0; column < columnCount; column++)
{
Cell cell = game.Level[row, column];
CellView cellControl;
if (!controlDictionary.TryGetValue(cell, out cellControl))
{
cellControl = new CellView(cell);
controlDictionary[cell] = cellControl;
DetachCellView(cellControl);
AttachCellView(cellControl);
gameLayout.Children.Add(cellControl);
}
AbsoluteLayout.SetLayoutBounds(cellControl, new Rectangle(left, top, cellSize, cellSize));
left += cellSize;
}
left = leftStart;
top += cellSize;
}
}
When a CellView
is tapped, the game attempts to walk the player to the location on the grid. See Listing 17. The event is processed by the Game
object.
Listing 17. MainPage HandleCellTap method
void HandleCellTap(object sender, EventArgs e)
{
if (levelCodeTextBox.IsFocused)
{
return;
}
CellView button = (CellView)sender;
Cell cell = button.Cell;
game.ProcessCellTap(cell, false);
}
The ProcessCellTap
method performs either a jump or a push. See Listing 18. When the player walks in a straight line and potentially pushes a treasure, this is called a push. Alternatively, a jump moves the player to any reachable cell without pushing any treasures in its path.
Listing 18. Game ProcessCellTap method
public void ProcessCellTap(Cell cell, bool shiftPressed)
{
GameCommandBase command;
if (shiftPressed)
{
command = new PushCommand(Level, cell.Location);
}
else
{
command = new JumpCommand(Level, cell.Location);
}
commandManager.Execute(command);
}
I’ve added a double tap gesture to the user's arsenal. If a CellView
is double tapped, the game will attempt to walk the player in a straight line; pushing a treasure if there is one in the player’s path. Again, the event is handled by the Game
object’s ProcessCellTap
method. See Listing 19.
Listing 19. MainPage HandleCellDoubleTap method
void HandleCellDoubleTap(object sender, EventArgs e)
{
CellView button = (CellView)sender;
Cell cell = button.Cell;
game.ProcessCellTap(cell, true);
}
When the size of the host view changes the app resizes the cells in its game grid to utilize all available space. Recall that the Page
object’s SizeChanged
event is raised when the orientation of the device changes.
HandleWindowSizeChanged
schedules a layout update one second after a size change occurs. See Listing 20. This prevents the app from being slowed down by multiple size changed events occurring at about the same time.
The Xamarin.Forms.Device.StartTimer
event provides a platform agnostic way to schedule work on the UI thread. If the action provided to the StartTimer
method returns true, the action will recur after the specified interval; otherwise the action isn’t invoked again.
Listing 20. MainPage HandleWindowSizeChanged method
void HandleWindowSizeChanged(object sender, EventArgs eventArgs)
{
if (layoutScheduled)
{
return;
}
layoutScheduled = true;
Device.StartTimer(TimeSpan.FromSeconds(1.0),
() =>
{
layoutScheduled = false;
LayoutLevel();
return false;
});
}
Implementing the Forms CellView
The CellView
class in the Outcoder.Sokoban.Launcher project represents a tile in the game. It may appear as a wall or a floor tile containing content. A floor cell’s content can be the player, a treasure, a goal; or a combination of these.
CellView
is a subclass of Xamarin.Forms.ContentView
, which I chose because it allows layering of multiple child views and appears to be fairly lightweight, which is important because the game grid requires many CellView
objects to be created. There is, however, room for optimization here. The CellView
’s Content
property is populated with a Grid
view. I had some spacing a layout issues that were solved by this configuration, however it’s not optimal and if you plan on leveraging this code I recommend looking at improving the structure of CellView
.
When a CellView
is created it sets itself up to respond to tap gestures. See Listing 21. In Xamarin Forms touch events are generally implemented using the GestureRecognizers
property of the View
class and not by, for exmple, direct subscription to a ‘Tap’ event. So, there’s a little extra plumbing you need to put in place; create a TapGestureRecognizer
, subscribe to it’s Tapped
event, and add it to the view’s GestureRecognizers
property.
The image or images that are displayed in the CellView
depend on the type of its associated Sokoban Cell
object.
Listing 21. CellView constructor
public CellView(Cell cell) : this()
{
this.cell = cell;
var tapRecognizer = new TapGestureRecognizer();
tapRecognizer.Tapped += HandleTapped;
GestureRecognizers.Add(tapRecognizer);
if (!(cell is SpaceCell))
{
if (cell is WallCell)
{
wallImage = AddChildTile(imageDir + "Wall.jpg");
}
else
{
if (cell is FloorCell || cell is GoalCell)
{
floorImage = AddChildTile(imageDir + "Floor.jpg");
floorHighlightImage = AddChildTile(imageDir + "FloorHightlight.png");
}
if (cell is GoalCell)
{
goalImage = AddChildTile(imageDir + "Goal.png");
goalActiveImage = AddChildTile(imageDir + "GoalActive.png");
}
treasureImage = AddChildTile(imageDir + "Treasure.png");
playerImage = AddChildTile(imageDir + "Player.png");
playerImage.Opacity = 0;
}
}
cell.PropertyChanged += HandleCellPropertyChanged;
UpdateDisplay();
}
An image must be created for each cell type and its content. See Listing 22.
The Xamarin.Forms.Image
class is derived from View
and views are always limited to a single parent view. It cannot, therefore, be cached and used multiple times within a page. However, each Image
relies on an ImageSource
object, which may be cached to reduce the app’s memory footprint.
Within the CellView
class there is a static Dictionary<string, ImageSource>
named imageCache
. This is where ImageSource
objects for each cell type and content are placed.
Listing 22. CellView CreateContentImage method
Image CreateContentImage(string fileName)
{
Image image = new Image
{
Aspect = Aspect.AspectFill
};
ImageSource sourceImage;
if (!imageCache.TryGetValue(fileName, out sourceImage))
{
sourceImage = ImageSource.FromFile(fileName);
imageCache[fileName] = sourceImage;
}
image.Source = sourceImage;
return image;
}
The ImageSource.FromFile
method allows you to retrieve your image data on any of the Xamarin supported platforms. If it's being used on iOS or Android, the directory path should not be included. On UWP, however, the full path to the image content resource must be supplied.
The CellView
class contains a static constructor that sets the imageDir
field according to the platform. See Listing 23. You use the Xamarin.Forms.Device
class’s static OS
property to determine what platform the app is running on. In this case if the app is running on iOS, Android, or some other platform then the image directory is set to an empty string. On iOS and Android resources are placed in a specific directory and are located using a unique name. In a UWP app, however, images can exist as content anywhere with the project.
Listing 23. CellView static constructor
static CellView()
{
var os = Device.OS;
if (os == TargetPlatform.Android
|| os == TargetPlatform.iOS
|| os == TargetPlatform.Other)
{
imageDir = string.Empty;
}
else
{
imageDir = "/Controls/CellControl/CellImages/";
}
}
Each image object is added to the CellView
via the AddChildTile
method. See Listing 24.
Listing 24. CellView AddChildTile
Image AddChildTile(string relativeUrl)
{
Image image = CreateContentImage(relativeUrl);
layout.Children.Add(image);
return image;
}
When the CellView
is first initialized or its associated Cell
object’s content changes the UpdateDisplay
method is called. See Listing 25. The visibility of each image within the CellView
is determined by the Cell
object’s type and it’s content.
Listing 25. CellView UpdateDisplay method
void UpdateDisplay()
{
if (wallImage != null)
{
wallImage.IsVisible = cell is WallCell;
}
if (floorImage != null)
{
floorImage.IsVisible = cell is FloorCell || cell is GoalCell;
}
if (floorHighlightImage != null)
{
floorHighlightImage.IsVisible = (cell is FloorCell || cell is GoalCell);
}
if (treasureImage != null)
{
treasureImage.IsVisible = cell.CellContents is Treasure;
}
if (playerImage != null)
{
playerImage.Opacity = 1;
playerImage.IsVisible = cell.CellContents is Actor;
}
if (goalImage != null)
{
goalImage.IsVisible = cell is GoalCell && !(cell.CellContents is Treasure);
}
if (goalActiveImage != null)
{
goalActiveImage.IsVisible = cell is GoalCell && cell.CellContents is Treasure;
}
}
Handling the CellView Tapped Gesture
One of the things I like most about this Sokoban game implementation is its touch friendly interface. Because of the game’s pathfinding capability, the user is able to move the player character to any reachable place on the game grid with a single tap. However, pusing a tile is not so easy without a keyboard. In a previous article I showed how when holding down the shift key while tapping, the user could push a cell multiple cells in a linear direction.
Well, of course, most mobile devices these days don’t have a hardware keyboard, so I needed another way to perform this move. I chose a double tap gesture. When the user double taps on a cell, if the player character is able, it will push a treasure to that cell.
Recall that we use a TapGestureRecognizer
to be notified when the user taps the CellView
. Unfortunately, in Xamarin Forms there isn’t a DoubleTapGestureRecognizer
, so we have to come up with another way to recognize when the user double-taps a CellView
. See Listing 26.
A tapCount
field is used to record the number of times the user has tapped the cell within the short time-frame of 500 milliseconds. If the user taps two times within this timeframe it’s considered a double tap; otherwise it’s considered a single tap.
Listing 26. CellView HandleTapped method.
void HandleTapped(object sender, EventArgs e)
{
tapCount++;
if (tapCount >= 2)
{
tapCount = 0;
isTimerSet = false;
OnDoubleTap(EventArgs.Empty);
return;
}
if (!isTimerSet)
{
isTimerSet = true;
Device.StartTimer(new TimeSpan(0, 0, 0, 0, 500), () =>
{
if (isTimerSet && tapCount == 1)
{
OnTap(e);
}
isTimerSet = false;
tapCount = 0;
return false;
});
}
}
When a DoubleTap
event occurs, the MainPage
’s HandleCellDoubleTap
method is called. See Listing 27. The method calls the game’s ProcessCellTap
method with the cell and a true shiftPressed
argument; indicating a push move is requested.
Listing 27. MainPage HandleCellDoubleTap method
void HandleCellDoubleTap(object sender, EventArgs e)
{
CellView button = (CellView)sender;
Cell cell = button.Cell;
game.ProcessCellTap(cell, true);
}
iOS Implementation of the Sokoban Game
The Infrastructure
implementation for iOS uses the Directory
class to enumerate the level files in the Levels directory of the Outcoder.Sokoban.Launcher.iOS project. See Listing 28.
The iOS implementation uses the same level naming strategy and relies on the level (.skbn) files having the same naming convention. Level files are linked files. That is, they were added to the project using the Add Existing Item dialog in combination with the Add As Link drop down. See Figure 3.
The level files have been linked into the Levels directory. The build action of each level file in the iOS project is set to BundleResource. Unlike the Android implementation, resources, such as the level files, don’t need to be located within a centralized Resources directory, but rather can be strewn throughout your project; much like in UWP or WPF.
Figure 3. Adding a Level file as a link.
Listing 28. iOS Infrastructure class
class Infrastructure : InfrastructureBase
{
readonly AudioClips audioClips;
const string levelDirectory = "Levels";
readonly List<string> fileList;
public Infrastructure(ISynchronizationContext synchronizationContext, AudioClips audioClips) : base(synchronizationContext)
{
this.audioClips = ArgumentValidator.AssertNotNull(audioClips, "audioClips");
fileList = Directory.EnumerateFiles(levelDirectory).ToList();
}
public override int LevelCount => fileList?.Count ?? 0;
public override Task<string> GetMapAsync(int levelNumber)
{
string fileName = $@"{levelDirectory}/Level{levelNumber:000}.skbn";
using (var stream = File.Open(fileName, FileMode.Open))
{
using (StreamReader reader = new StreamReader(stream))
{
string levelText = reader.ReadToEnd();
return Task.FromResult(levelText);
}
}
}
public override void PlayAudioClip(AudioClip audioClip)
{
audioClips.Play(audioClip);
}
}
The AudioClips
class in the Outcoder.Sokoban.Launcher.iOS project is responsible for playing each sound effect. See Listing 29.
This class follows the same pattern as the Android implementation. An AVAudioPlayer
is created to play back each of the sound effects .mp3 files. When the AudioClips
object receives a request to play an AudioClip
, it selects the applicable AVAudioPlayer
instance and calls its PlayAtTime
method.
Listing 29. iOS AudioClips class
class AudioClips
{
readonly AVAudioPlayer levelIntroductionElement;
readonly AVAudioPlayer gameCompleteElement;
readonly AVAudioPlayer levelCompleteElement;
readonly AVAudioPlayer footstepElement;
readonly AVAudioPlayer treasurePushElement;
readonly AVAudioPlayer treasureOnGoalElement;
internal AudioClips()
{
const string audioDir = "Audio/";
levelIntroductionElement = CreateElement(new NSUrl(audioDir + "LevelIntroduction.mp3"));
gameCompleteElement = CreateElement(new NSUrl(audioDir + "GameComplete.mp3"));
levelCompleteElement = CreateElement(new NSUrl(audioDir + "LevelComplete.mp3"));
footstepElement = CreateElement(new NSUrl(audioDir + "Footstep.mp3"));
treasurePushElement = CreateElement(new NSUrl(audioDir + "TreasurePush.mp3"));
treasureOnGoalElement = CreateElement(new NSUrl(audioDir + "TreasureOnGoal.mp3"));
}
AVAudioPlayer CreateElement(NSUrl url)
{
string fileTypeHint = url.PathExtension;
NSError error;
var result = new AVAudioPlayer(url, fileTypeHint, out error);
if (error != null)
{
Debug.WriteLine(error.LocalizedFailureReason);
Debugger.Break();
}
return result;
}
void Play(AVAudioPlayer element)
{
element.PlayAtTime(0.0);
}
internal void Play(AudioClip audioClip)
{
switch (audioClip)
{
case AudioClip.Footstep:
Play(footstepElement);
return;
case AudioClip.GameComplete:
Play(gameCompleteElement);
return;
case AudioClip.LevelComplete:
Play(levelCompleteElement);
return;
case AudioClip.LevelIntroduction:
Play(levelIntroductionElement);
return;
case AudioClip.TreasureOnGoal:
Play(treasureOnGoalElement);
return;
case AudioClip.TreasurePush:
Play(treasurePushElement);
return;
}
}
}
The AppDelegate
class in the Outcoder.Sokoban.Launcher.iOS project is analogous to the MainActivity
in the Android implementation. See Listing 30.
The Infrastructure class constructor receives a UISynchronizationContext
instance and an AudioClips
instance. The App
instance is then provided to the FormsApplicationDelegate.LoadApplication
method, et voilà, the iOS app comes alive.
Listing 30. AppDelegate class
[Register("AppDelegate")]
public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
{
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
global::Xamarin.Forms.Forms.Init();
var audioClips = new AudioClips();
var infrastructure = new Infrastructure(new UISynchronizationContext(), audioClips);
LoadApplication(new App(infrastructure));
return base.FinishedLaunching(app, options);
}
}
The expanded menu is shown in Figure 4.
Figure 4. Sokoban Running on iPad Simulator with Menu Expanded
Conclusion
In this article you looked at porting the Sokoban game to both iOS and Android. You saw how nearly all code is shared across both platforms. You observed some platform difference, such as those encountered when defining and referencing image resources, and at implementing audio playback for each platform. You looked at abstracting platform differences and how to provide platform specific implementations at runtime.
You saw how to work with XAML in Xamarin forms. You explored rudimentary views, such as text boxes (aka Entry views), labels and buttons. You also looked at more advanced topics, such as creating a slide-in menu and defining reusable XAML resources. You also saw how to use an AbsoluteLayout
to layout the game grid, and at utilizing the game grid's available space.
You also explored touch gestures, and we introduced a new double tap move which allows the Sokoban character to push a treasure across multiple floor spaces.
I hope you find this project useful. If so, then please rate it and/or leave feedback below.
History
November 25 2016