iOS/macOS cross-platform development

This article describes the tiny set of cross-platform features that I implemented to make my client for the Text Message Bus run on both platforms, iOS and macOS.

Introduction

The GUI libraries AppKit on macOS and UIKit on iOS may at first seem very different, and supperficially they really are. When developing UIKit for iOS, Apple changed almost all class and type names. Many methods got new names or slightly changed signatures.

But ignoring the change in names, much is still the same. By managing the differences, it is possible to share much, perhaps almost all, code between macOS and iOS.

This is in no way a complete solution. It just covers what I needed for one rather small program. But it shows that portability is possible and can come at a low cost, and may serve as inspiration and a starting point.

Summary of identifiers

These are all the identifiers that are defined.

#define IOS Set os iOS
#define MACOS Set on macOS
#define CtAppDelegateBase Base class for application delegate
#define ct_current_context Get current Core Graphics context
@interface CtButton Subclass of either UIButton or NSButton
typedef CtBezierPath Either UIBezierPath or NSBezierPath
typedef CtControl
typedef CtImage
typedef CtView
typedef CtViewController
typedef CtWindow
typedef CtBezierPath
NSView method -setNeedsDisplay iOS only: Customize NSView
UIView method -identifier
CtButton method -title iOS: Emulate macOS NSButton
CtButton method -setTitle iOS: Emulate macOS NSButton
function ct_button_create Create UIButton or NSButton
function ct_button_connect Connect action to target
function ct_view_background Set transparent background
function macos_wants_layer Empty on iOS
function macos_adjust_children_vertically Compansate for different coordinate systems

platform.h

# include <TargetConditionals.h>
# if TARGET_OS_SIMULATOR || TARGET_OS_IPHONE     -- iOS: Foundation + UIKit 
#     import 
#     define IOS 1
# elif TARGET_OS_MAC                             -- macOS: Foundation + AppKit 
#     import 
#     define MACOS 1
# else
#     error "Unknown platform"
# endif

//--------------------------------------------------------------------------------------------------------------
# ifdef IOS
#     define CtAppDelegateBase      UIResponder 

      typedef UIBezierPath          CtBezierPath;
      typedef UIControl             CtControl;
      typedef UIImage               CtImage;
      typedef UIView                CtView;
      typedef UIViewController      CtViewController;
      typedef UIWindow              CtWindow;

      @interface CtButton : UIButton -- See platform-ios.m 
          - (NSString *)   title;
          - (void      )setTitle :(NSString *)title;
      @end

      @interface UIView (Compat)
          - (NSString *)identifier; -- Emulate macOS [ NSView -identifier ]
      @end
# else
#     define CtAppDelegateBase      NSObject 

      typedef NSBezierPath          CtBezierPath;
      typedef NSButton              CtButton;
      typedef NSControl             CtControl;
      typedef NSImage               CtImage;
      typedef NSView                CtView;
      typedef NSViewController      CtViewController;
      typedef NSWindow              CtWindow;

      @interface NSView (Compat)    -- See platform-ios.m 
          - (void)setNeedsDisplay;  -- Emulate iOS [ UIView -setNeedsDisplay ] 
      @end
# endif

//--------------------------------------------------------------------------------------------------------------
CtButton  *ct_button_create  (NSString *title, CtImage *image, id target_object, NSString *target_selector_name);
void       ct_button_connect (CtButton *from_button,           id target_object, NSString *target_selector_name);
void       ct_view_background(CtView   *view);
CGContext *ct_current_context();
//
// macos_* does something on macOS, but nothing on iOS
//
void      macos_wants_layer               (CtView *);
void      macos_adjust_children_vertically(CtView *parent);

UIKit is GUI toolkit used on iOS. AppKit is the GUI toolkit used on macOS. Foundation is the non-GUI library common to both platforms. Think of Foundation as the Objective-C runtime. It is in Foundation we find things like NSObject, NSString and NSLog.

Cocoa is the name for the combination of Foundation and AppKit. It effectively makes up the whole programming framework for macOS. Similarly Cocoa Touch is the name for the combination of Foundation and UIKit, the programming framework for iOS. Since these two terms are used inconsistently, I will not used them at all.

Methods with almost identical signatures

Some methods have slighty differing signatures:

    [ UIButton -setTitle :text forState:UIControlStateNormal ]  -- iOS method 
    [ NSButton -setTitle :text                               ]  -- macOS property setter

    [ UIButton -currentTitle        ]   -- iOS method
    [ NSButton -title               ]   -- macOS property getter

    [ UIView -restorationIdentifier ]   -- iOS   property getter
    [ NSView -identifier            ]   -- macOS property getter

    [ UIView -setNeedsDisplay       ]   -- iOS method
    [ NSView -setNeedsDisplay :flag ]   -- macOS property setter

A button’s title is a property on macOS, where a button has only one title. On iOS there are several titles, for different “states”, so title is no longer a property. Similarly, needsDisplay is a read/write boolean property on macOS, but not on iOS. Finally, on iOS, identifier is gone, and is replaced by restorationIdentifier. An alternative to using identifier/restorationIdentifier is to use accessibilityIdentifier, available on both platforms.

Below I will describe some methods to bridge these differences.

Portaibility using plain function

Consider the method setNeedsDisplay. It requires a parameter on macOS (NSView) but not on iOS (UIView).

      void ct_view_needs_display(CtView *view);    -- See platform.h

      void ct_view_needs_display(CtView *view) {   -- See platform.m
      # ifdef IOS
          [ view setNeedsDisplay ];
      # else
          [ view setNeedsDisplay :true ];
      # endif
      }

Portiability through subclassing

To add the macOS-style title and setTitle methods to iOS, create a subclass of UIButton:

      @interface CtButton : UIButton    -- See platform.h
          - (NSString *)   title;
          - (void      )setTitle :(NSString *)title;
      @end

      @implementation CtButton          -- See platform-ios.h
          - (NSString *) title {
              return  [ self currentTitle ];
          }

          - (void)setTitle :(NSString *)title {
              [ self setTitle :title forState:UIControlStateNormal ];
          }
      @end

On macOS, a typedef is enough if we have no need to extend NSButton.

      typedef NSButton ctButton;

Subclassing works well if:

  • The superclass was is designed for subclassing. If it has init methods, then it probably is. If it has factory methods rather than init methods, the probably not.
  • We have explicit control of the class of created objects. This is true for any object that we alloc and init in code, as well as for most objects created in Interface Builder.

There are several cases when we have no control of the class of an object.

One case is where we subclass UIView. Framework subclasses like UITextField are still based on UIView, and know nothing about our subclass. For a way around this, se customization, below.

Another case is factory methods. They may return the type of the sending class, but they may also completely ignore the class of the sender, and instead return any object that matches the return type of the method. Consider the colorWithRed factory methods of UIColor/NSColor:

      [NSColor + (NSColor *)colorWithRed:(CGFloat) green:(CGFloat) blue:(CGFloat) alpha:(CGFloat);  -- iOS
      [UIColor + (UIColor *)colorWithRed:(CGFloat) green:(CGFloat) blue:(CGFloat) alpha:(CGFloat);  -- macOS

They happen to return NSColorSpaceColor and UIDeviceRGBColor, both undocumented. Other Color factory methods return other classes.

Consider the BezierPath curve classes and the bezierPathWithRect factory method.

      [ UIBezierPath  +(instancetype  )bezierPathWithRect:(CGRect);   -- iOS
      [ NSBezierPath  +(NSBezierPath *)bezierPathWithRect:(NSRect);   -- macOS

Consider this code on iOS:

      @interface CtBezierPath : UIBezierPath @end;

      CtBezierPath *p = [ CtBezierPath bezierPathWithRect:some_rect ];

This happens to work since a) the return type of bezierPathWithRect is instancetype, in this case a pointer to CtBezierPath, and b) the runtime type of the object returned really is a pointer to CtBezierPath.

Now consider this code on macOS:

      @interface CtBezierPath : NSBezierPath @end;

      CtBezierPath *p = (CtBezierPath *)[ CtBezierPath bezierPathWithRect:some_rect ];

This also happens to work, since the runtime type of the object returned really is a pointer to CtBezierPath.

But beware that all of this is undocumented. There is nothing in the documentation to explain the difference in behaviour between the Color and the BezierPath classes. It may well change. Unless a class is clearly designed and documented for subclassing, do not subclass, even if it happens to work.

Portability through extensions to existing classes

In both Objective-C and Swift, existing classes can be extended by having methods and properties added to them. Extending is not the same thing as subclassing. When we subclass we get a new class; when we extend a class, we modify an existing class. Extending is possible even for classes for which the source code is not available. Extending an existing class is, somewhat misleading, called customization.

Again, consider the method setNeedsDisplay. It requires a parameter on macOS (NSView) but not on iOS (UIView).Let’s add the parameterless version to NSView:

      @interface NSView (Compat)         -- See platform.h
          - (void)setNeedsDisplay;       -- iOS: Emulate [ UIView -setNeedsDisplay ]
      @end

      @implementation NSView (Compat)    -- See platform-macos.h
      - (void)setNeedsDisplay {
          [ self setNeedsDisplay :YES ];
      }
      @end

The Compat within brackets is called the Category Identifier. It can be any identifier. It must match in the implementation, but is otherwise not used. Its presence say that here we have an extension of an existing class. Several extensions, all with their own Category Identifiers are allowed.

This is cool, but dangerous. What happens if you replace an existing method? What happens if a later version of the framework introduces the same method. One solution is to use some unlikely-to-clash prefix, but that would rather defeat the purpose in this case. Customization may also be confusing to a programmer new to the code, since there is nothing to indicate that a certain method is, in fact, not a standard method.

I personally limit the use of this feature.

Description of utility routines

ct_button_create() — Create similar-looking buttons, and connect an action handler.

ct_button_connect() — Helper for ct_button_create().

ct_view_background() — Sets background to transparent. This is the default on macOS.

ct_view_needs_display() — Force redisplay using setNeedsDisplay.

cg_current_context() — Get the current Core Graphics context, in e.g. drawRect. Not that the prefix is cg, for Core Graphics.

macos_wants_layer() — On macOS, sends setWantsLayer to a view. On iOS there is always a layer so this routine does nothing.

macos_adjust_children_vertically — Before calling this function, position subviews in a way that works on iOS, but would be in reverse vertical order on macOS. Then call this function to automatically rearrange them. The containing view must have the right height, since it is used to compute the new vertical positions. On iOS, does nothing.

You can reach me by email at “lars dash 7 dot sdu dot se” or by telephone +46 705 189090

View source for the content of this page.