Search code examples
iosswiftxcodeautolayoutinterface-builder

Scaling a label placed over an image with Auto Layout (IB)?


In Xcode, I'm trying to place a label over an image using auto layout with Interface Builder. The point is to have the image scale to the different devices, while also scaling the label, so that it still remains in the same position over the image.

It is a very similar question to this, minus the button: How do I position a label and a button on an image so that even if the image is scaled they are at the same place on the image?

In that post, the asker mentioned that the second answer was as close as they got to solving the problem. It included using "filler views" to constrain the label so that it moved with the image. But the asker was confused (as am I) with how to implement those constraints. Can anyone further explain how to do this? Or maybe have another method?


Solution

  • Because you say the label will be a "stopwatch" type of image, with the text formatted to "00:00:00" I'll assume you're using a fixed-width font.

    For this example, I'll use Courier New Bold, and I'll assume the app is running on an iPhone in Portrait mode. The same information will apply for Landscape orientation, or on iPad... you'd just need to set up your sizing accordingly.

    This is the image I'll use:

    enter image description here

    It can be any size, and can have @2x / @3x sizes... the important thing is that we know its aspect ratio. In this case, my image is 600 x 800, which is a 3:4 ratio.

    We want to setup our layout for the widest expected size -- so we'll use iPhone 13 Pro Max.

    Embed the imageView and the label in a "holder view." We'll be setting the label y-position relative to the bottom of the imageView, and this will keep it constant regardless of position on screen.

    Make sure the holder view has an aspect-ratio constraint matching the image, and constrain the imageView at Zero to all four sides of the holder view.

    Set the font so it fits nicely in the "label area." Constrain its width proportionally to the imageView. In this case, a multiplier of 0.85 works well.

    Constrain the label's CenterY to the imageView's bottom, with a multiplier that puts it in place. In this case, 0.25 works.

    Enable Autoshrink on the label, with Minimum Font Scale of 0.25 (even though it's unlikely it will ever get that small).

    Important: Set the label's Baseline to Align Centers ... that will keep the text vertically aligned where we want it.

    Here's how it looks in Storyboard:

    enter image description here

    and at a couple different widths at run-time:

    enter image description here

    enter image description here

    Here's the source for the Storyboard so you can inspect the constraints and element properties:

    <?xml version="1.0" encoding="UTF-8"?>
    <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19455" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Xg6-6D-sKc">
        <device id="retina6_7" orientation="portrait" appearance="light"/>
        <dependencies>
            <deployment identifier="iOS"/>
            <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19454"/>
            <capability name="Safe area layout guides" minToolsVersion="9.0"/>
            <capability name="System colors in document resources" minToolsVersion="11.0"/>
            <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
        </dependencies>
        <scenes>
            <!--Timer View Controller-->
            <scene sceneID="gRg-mL-Zeo">
                <objects>
                    <viewController id="Xg6-6D-sKc" customClass="TimerViewController" customModule="SW15Scratch" customModuleProvider="target" sceneMemberID="viewController">
                        <view key="view" contentMode="scaleToFill" id="wLy-zd-hy6">
                            <rect key="frame" x="0.0" y="0.0" width="428" height="926"/>
                            <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                            <subviews>
                                <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XHF-SM-3c4">
                                    <rect key="frame" x="0.0" y="177.66666666666669" width="428" height="570.66666666666652"/>
                                    <subviews>
                                        <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bkg" translatesAutoresizingMaskIntoConstraints="NO" id="2Xc-0o-ie7" userLabel="ImageView">
                                            <rect key="frame" x="0.0" y="0.0" width="428" height="570.66666666666663"/>
                                        </imageView>
                                        <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="00:00:00" textAlignment="center" lineBreakMode="tailTruncation" minimumScaleFactor="0.25" translatesAutoresizingMaskIntoConstraints="NO" id="TYc-HH-6id">
                                            <rect key="frame" x="32" y="100.33333333333334" width="364" height="85"/>
                                            <fontDescription key="fontDescription" name="CourierNewPS-BoldMT" family="Courier New" pointSize="75"/>
                                            <nil key="textColor"/>
                                            <nil key="highlightedColor"/>
                                        </label>
                                    </subviews>
                                    <color key="backgroundColor" systemColor="systemYellowColor"/>
                                    <constraints>
                                        <constraint firstItem="TYc-HH-6id" firstAttribute="centerX" secondItem="2Xc-0o-ie7" secondAttribute="centerX" id="Mca-tA-1Yt"/>
                                        <constraint firstAttribute="trailing" secondItem="2Xc-0o-ie7" secondAttribute="trailing" id="Vgz-B5-aq3"/>
                                        <constraint firstItem="TYc-HH-6id" firstAttribute="centerY" secondItem="2Xc-0o-ie7" secondAttribute="bottom" multiplier="0.25" id="ZVs-Ut-o2V"/>
                                        <constraint firstItem="TYc-HH-6id" firstAttribute="width" secondItem="2Xc-0o-ie7" secondAttribute="width" multiplier="0.85" id="h5u-Vx-VIZ"/>
                                        <constraint firstAttribute="width" secondItem="XHF-SM-3c4" secondAttribute="height" multiplier="3:4" id="hD5-0n-cot"/>
                                        <constraint firstAttribute="bottom" secondItem="2Xc-0o-ie7" secondAttribute="bottom" id="u7H-o7-HPd"/>
                                        <constraint firstItem="2Xc-0o-ie7" firstAttribute="leading" secondItem="XHF-SM-3c4" secondAttribute="leading" id="v8V-GX-3Aj"/>
                                        <constraint firstItem="2Xc-0o-ie7" firstAttribute="top" secondItem="XHF-SM-3c4" secondAttribute="top" id="xKB-NI-2RP"/>
                                    </constraints>
                                </view>
                            </subviews>
                            <viewLayoutGuide key="safeArea" id="93z-as-uJR"/>
                            <color key="backgroundColor" systemColor="systemBackgroundColor"/>
                            <constraints>
                                <constraint firstItem="XHF-SM-3c4" firstAttribute="centerX" secondItem="93z-as-uJR" secondAttribute="centerX" id="IfB-NL-VTA"/>
                                <constraint firstItem="XHF-SM-3c4" firstAttribute="width" secondItem="93z-as-uJR" secondAttribute="width" id="Vad-XH-7tx"/>
                                <constraint firstItem="XHF-SM-3c4" firstAttribute="centerY" secondItem="wLy-zd-hy6" secondAttribute="centerY" id="znr-ky-5ns"/>
                            </constraints>
                        </view>
                        <connections>
                            <outlet property="holderView" destination="XHF-SM-3c4" id="tIP-go-kGI"/>
                            <outlet property="hvWidth" destination="Vad-XH-7tx" id="OGg-sX-LqR"/>
                            <outlet property="theLabel" destination="TYc-HH-6id" id="End-Tr-hNP"/>
                        </connections>
                    </viewController>
                    <placeholder placeholderIdentifier="IBFirstResponder" id="4eF-f0-IGU" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
                </objects>
                <point key="canvasLocation" x="1536" y="113"/>
            </scene>
        </scenes>
        <resources>
            <image name="bkg" width="300" height="400"/>
            <systemColor name="systemBackgroundColor">
                <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
            </systemColor>
            <systemColor name="systemYellowColor">
                <color red="1" green="0.80000000000000004" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
            </systemColor>
        </resources>
    </document>
    

    and an example controller -- each tap anywhere will reduce the width by 5%:

    class TimerViewController: UIViewController {
        
        @IBOutlet var theLabel: UILabel!
        @IBOutlet var holderView: UIView!
        
        @IBOutlet var hvWidth: NSLayoutConstraint!
        
        // start Timer Countdown at 2-hours
        var seconds: Int = 60 * 60 * 2
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            self.updateLabel()
        }
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            
            Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
                self.seconds -= 1
                if self.seconds < 0 {
                    timer.invalidate()
                }
                self.updateLabel()
            }
            
        }
        func updateLabel() -> Void {
            let formatter = DateComponentsFormatter()
            formatter.allowedUnits = [.hour, .minute, .second]
            formatter.unitsStyle = .positional
            formatter.zeroFormattingBehavior = .pad
            if let formattedString = formatter.string(from: TimeInterval(self.seconds)) {
                self.theLabel.text = formattedString
            }
        }
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            var m = hvWidth.multiplier
            m -= 0.05
            if m < 0.25 {
                m = 1.0
            }
            hvWidth.isActive = false
            hvWidth = holderView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: m)
            hvWidth.isActive = true
        }
    }