Views Have Fixed Sizes

One of the first things presented about SwiftUI is that its views determine their own sizes, and that those sizes are fixed. This is such a simple statement that it’s easy to move on quickly to get to the good stuff.

This is a mistake, because it’s deceptive in its simplicity; this statement fundamentally changes everything you know about layout. Until you understand all of the repercussions of it, you’ll be constantly feeling like you’re fighting SwiftUI for even the simplest layouts.

In this post we’ll look at two views that form the basic building blocks of many layouts, and what this assertion means for them: Image, and Text.

Images

Let’s start by placing an Image in our layout:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
    }
}

The specific image we’ve chosen has a size of 101 × 92 pixels, and we might be slightly surprised that the resulting view has the same size, and is centered within the layout of our device, instead of filling it.

an image

We might try using the .frame modifier on the view to change its frame:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .frame(width: 200, height: 200)
    }
}

But this doesn’t work either:

an image inside a frame

Rather than change the size of the image, it’s simply centered within the frame we specified. In fact, .frame does not change the view it modifies; it creates a new view with the specified size, and positions the view it’s modifying as a child inside it.

.frame does not change the view it modifies; it creates a new view with the specified size, and positions the view it’s modifying as a child inside it.

That’s worth repeating because it’s a fundamental of SwiftUI: you cannot directly change the size of a view. .frame, like all modifiers, actually returns a new view containing the modified view as a child.

The size of an image is fixed, even if you try and place it inside a frame that’s too small for it:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .frame(width: 50, height: 200)
    }
}

We might expect it to be resized in this case, even if not the other, but in fact it simply overflows its bounds:

an image overflowing a frame

The choice of overflowing by default instead of clipping can be customized through .clipped, however inspection will reveal that the Image still has its fixed size, and that this modifier simply affects drawing. Since this post is about layout, we won’t dwell on drawing modifiers.

We’ll come back to images again, but we need to learn more first, so we’ll take a look at Text instead.

Text

Let’s replace the Image in our layout with some Text instead, perhaps the name of our barbarian character:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
    }
}
a text

Text too has a fixed size, which is simply the size necessary to render the string given to it.

We might try seeing how this looks inside a .frame:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .frame(width: 200, height: 200)
    }
}

Knowing that a .frame creates a new view that contains the Text, we we should not be surprised that the size of the Text remains the same as before, and that it’s simply centered within the frame:

a text inside a frame

Unlike Image we actually do have a simple way of changing the fixed size of Text, albeit indirectly, by specifying the .font that we want to use.

It’s our hero’s name, so let’s use a .title font:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
    }
}

The view will still have a fixed size, but since it’s using a larger font, we’d expect it to have a larger fixed size than it had before:

a text with a title font

This demonstrates that a view’s fixed size is chosen by the view itself, and that as a developer we can change that as long as we’re using modifiers that adjust environment that the view uses, such as .font.

a view’s fixed size is chosen by the view itself

So to complete the loop, let’s place our larger title-font Text inside a frame that would be too small for it:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .frame(width: 200, height: 200)
    }
}

Given that we know views have fixed sizes, and from what we saw with similar code using Image, we should be expecting the Text to simply overflow its bounds just like the Image did:

a multi-line text in a frame

But it didn’t!

Instead of overflowing the bounds of its parent, the Text was able to return a different fixed size that fit within. In this case it wrapped its content, returning a size lesser in width but greater in height.

This doesn’t contradict what we’ve just learned, it actually demonstrates another key principle we haven’t learned yet: a view’s fixed size is chosen by the view itself, but a view receives the size of its parent before deciding its own size.

a view receives the size of its parent before deciding its own size

Advanced Text Layout

Understanding what happened in the previous example, and the process by which the views were sized, and positioned, is key to understanding the flexible nature of SwiftUI’s fixed size layouts.

The Text view is a fixed size once laid out, but is flexible about what it can return as that fixed sized, depending on the size proposed for its parent, in this case our 200 × 200 frame.

When the frame positions the Text inside it, it first informs the Text that its own proposed size is 200 × 200, and only then asks the Text how large it is. This means that the Text can decide a different answer depending on the size of its parent.

In this case, the width available is insufficient to render the entire string, and Text is able to wrap it to fit. Once it has determined the size of the wrapped string fits within the proposed frame, it then returns the size necessary to render that as its own size.

This size is still usually smaller than the parent’s size, it does not grow to fit. As you can see in the example, it did not simply return the parent width as its own width, it returned the largest width necessary for the wrapped string, and it was still necessary for the frame to center the text within it.

Wrapping is not Text‘s only trick for returning a different fixed size, it can also tighten by compressing space between characters, and it can decrease its width by truncating its content, rendering a shortened version of the string with an ellipsis:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .lineLimit(1)
            .frame(width: 200, height: 200)
    }
}

Here we’ve added a .lineLimit modifier removing the default unlimited number of lines and replacing it with a new value of 1, this forces Text to use a different approach for returning a different fixed size:

Since the Text wasn’t able to fit the full width of the string in the space available, and was not permitted to use multiple lines, instead of wrapping, the Text was now able to decrease it’s with by truncating the text instead.

Note still that the width of the Text is only enough to render the truncated line, it is still centered within the frame.

Resizable Images

Now that we’ve learned that a view being a fixed size doesn’t mean the view can’t be flexible about what that fixed size is, we can revisit Image.

By default images have the fixed size of their source, but they have the option to be resizable:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .resizable()
            .frame(width: 200, height: 150)
    }
}

A resizable image simply returns the size of its parent as its own size, entirely filling any available space:

a resizable image

By default the image will fill the space by stretching the single source image, but by using .resizable(resizingMode: .tile) it would have repeated the source image:

a tiled resizable image

Note that the size of the Image is still the size of the frame, it’s simply rendering the source image multiple times within its bounds.

In many cases neither of those behaviors are what we want, and instead we want the image to fill as much of the parent as possible, while retaining the aspect ratio of its source. Fortunately there’s a modifier to specify that too:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 200, height: 150)
    }
}

Note that we still need the .resizable modifier, and that the ordering of the modifiers matters since it’s the resizable image we wish to constrain the aspect ratio of, not the frame:

a resizable image with an aspect ratio in a frame

The Image has chosen to use all of the parent’s available height, scaling the source up to fit, but has chosen a width that retains the aspect ratio of the original source. The Image therefore no longer has the full size of its parent, and the parent centers it.

With these rules firmly in our minds, we can now look at combining multiple views in our layouts by using stacks, or look at the interesting case of flexible frames.


Imagery used in previews by Kaiseto, original images and derived here licensed under Creative Commons 3.0 BY-NC-SA.

Xcode: two Apps with a shared private Framework, in a Workspace

I’ve been working on a reasonably large iOS project that has ended up consisting of multiple, related, iOS apps for which I’ve wanted to share a large amount of code through the use of shared, private, frameworks. This seems like it should be simple, but instead I’ve run into all kinds of issues.

When building, you might end up with a warning about a missing directory:

ld: warning: directory not found for option '-F…/build/Debug-iphoneos'/

When trying to start the app in the iOS Simulator, without Xcode attached, it might crash with the error image not found:

Dyld Error Message:
  Library not loaded: @rpath/….framework/…
  Referenced from: …/Library/Developer/CoreSimulator/Devices/…
  Reason: image not found

If you try and run the app on a iOS device at all, whether with Xcode attached or not, you might also end up with an image not found error:

dyld: Library not loaded: @rpath/….framework/…
  Referenced from: /var/mobile/Containers/Bundle/Application/…/….app/…
  Reason: image not found

And most infuriating of all, once you come to create an archive of your app and attempt to validate it for TestFlight or the iOS App Store, you might end up with an error about missing BCSymbolMap files:

An error occurred during validation
The archive did not contain <DVTFilePath:…:‘…/Library/Developer/Xcode/Archives/….xcarchive/BCSymbolMaps/….bcsymbolmap’> as expected.

Searching the Internet has yielded increasingly complicated, and convoluted solutions up to, and including, custom build scripts. None of these were palatable, and after some experimentation, I believe I’ve found the way to make this work perfectly!

First you’ll want to create a new workspace to keep everything together. Use File → New → Workspace…, create a New Folder for your workspace to live in, and pick a name for the workspace. This will give you a new Xcode window, which unlike a usual project, contains no targets or files.

Now we’ll create the project for the first iOS app. With the workspace open, use File → New → Project…, pick the template, and enter the product name, etc. as usual. When you come to choose the location, make sure that the Add to: box has the workspace you selected.

This will return you to your workspace, but now with your new project added to it. Do it again for the second project, and be doubly careful at the last screen. The Add to: box will default to the workspace, but now there’s a new Group: box, and that defaults to the project you just created, not the workspace, so make sure you change it!

Now we can add the framework for the shared code. Once again use File → New → Project…, and this time pick the Cocoa Touch Framework project type.

Give your framework a name on the next screen, and again choose a location within your workspace folder, with the Add to: box containing the workspace, and changing the Group: box from the last project, to the workspace:

Now you have a workspace containing your two applications, and a framework for the code that you want them to share. The next bit is the tricky bit; we need both apps to depend on the framework, and while there are a number of ways of doing this, only one seems to work consistently for me without running into any of the warnings or errors above.

First you’ll want to reveal the Products of the framework you just added, and note that the final framework product is red, indicating that it doesn’t yet exist.

This is the first thing we want to fix, if we try and set things up without it being built, Xcode sometimes does the wrong thing! From the Scheme menu, select the framework itself, and from the Destination menu, select Generic iOS Device. Hit ⌘B or Product → Build to build the framework itself, and note that it turns black in the files list.

It’s safe to add it to the app. Select your first iOS app target, make sure the General tab is selected, and scroll until you can see Embedded BinariesDrag the framework from the left hand list, and drop it into this section. I’ve found that dragging, rather than using the “+” button; and using the Embedded Binaries section, rather than Linked Frameworks and Libraries one, is key to making Xcode work.

This will not just add it as an embedded binary, it will also add it to the Linked Frameworks and Libraries section as well, and will create a copy of the framework under the app project in the left hand list.

We’re not quite there… one more thing to do, select that copy of the framework from the files list for the app project—not the framework project—and make sure you can see the File Inspector. Note that the Location currently has Absolute Path, you need to change this to Relative to Build Products:

Do the same sequence for your second app.

By following these steps:

  • the App depends on the Framework, so builds with the latest version of the Framework source each time;
  • the iOS Simulator correctly embeds the Debug-iphonesimulator version of the Framework,
  • which means that the app will run both when attached to Xcode, and when not attached;
  • when built for the iOS Device, it correctly embeds the Debug-iphoneos version of the Framework,
  • so the app runs on the device both when attached to Xcode, and when not attached;
  • and the correct target versions are built and embedded when archiving,
  • which means you can validate and upload with both app symbols and bitcode.