Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save allenhumphreys/4d2cf22c63a7de66170597f9ff0afecd to your computer and use it in GitHub Desktop.
Save allenhumphreys/4d2cf22c63a7de66170597f9ff0afecd to your computer and use it in GitHub Desktop.
A custom layout that allows for a vertically center-aligned text, next to an SF symbol
import SwiftUI
struct ContentView: View {
@AppStorage("showAlignments") var showAlignments = true
@AppStorage("showBorders") var showBorders = true
@State var iconScale: Image.Scale = .large
@State var fontStyle: Font.TextStyle = .body
@State var icon: String = "square.and.arrow.up"
let icons: [String] = ["square.and.arrow.up", "rectangle.and.pencil.and.ellipsis"]
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Toggle("Show alignments", isOn: $showAlignments)
.fixedSize()
Toggle("Show borders", isOn: $showBorders)
.fixedSize()
Picker("Image Scale", selection: $iconScale) {
ForEach([Image.Scale.small, .medium, .large].reversed(), id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.segmented)
.fixedSize()
Picker("Text Style", selection: $fontStyle) {
ForEach([Font.TextStyle.caption, .body, .title], id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.segmented)
.fixedSize()
Picker("Icon", selection: $icon) {
ForEach(icons, id: \.self) {
Image(systemName: $0)
}
}
.pickerStyle(.segmented)
.fixedSize()
HStack {
VStack {
Text("Custom")
.lineLimit(nil)
// Debug {
Button("Share", systemImage: icon) {
}
.debugBorder(.yellow)
.foregroundStyle(.red)
// }
.labelStyle(.titleAndIconBaselineAdjusted)
}
VStack {
Text("baseline")
.lineLimit(nil)
Button(action: { }) {
HStack(alignment: .firstTextBaseline) {
Image(systemName: icon)
.foregroundStyle(.tint)
Text("Share")
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.fill, in: Capsule())
}
}
VStack {
Text("center")
.lineLimit(nil)
Button(action: { }) {
HStack {
Image(systemName: icon)
.foregroundStyle(.tint)
Text("Share")
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.fill, in: Capsule())
}
}
// VStack {
// Text("Icon Only")
// .lineLimit(nil)
//
// Button(action: { }) {
// Image(systemName: icon)
// .foregroundStyle(.tint)
// // .offset(y: -1.333)
// .padding(10)
// .background(.fill, in: Circle())
// }
// }
}
}
// .environment(\.layoutDirection, .rightToLeft)
.font(.system(fontStyle))
.imageScale(iconScale)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(50)
.environment(\.showAlignments, showAlignments)
.environment(\.showBorders, showBorders)
}
}
// MARK: The implementation
struct MyTitleAndIconLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
CustomLayout {
Debug {
configuration.icon
}
Debug {
configuration.title
}
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(.fill, in: Capsule())
}
}
extension LabelStyle where Self == MyTitleAndIconLabelStyle {
static var titleAndIconBaselineAdjusted: MyTitleAndIconLabelStyle {
MyTitleAndIconLabelStyle()
}
}
struct CustomLayout: Layout {
let spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
precondition(subviews.count == 2)
var height: CGFloat = 0
var width: CGFloat = 0
for s in subviews {
let size = s.sizeThatFits(proposal)
height = max(height, size.height)
width += size.width
}
// obviously not correct
width += spacing
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
precondition(subviews.count == 2)
/// Calculate the adjustment needed to re-align the icon
let baselineDiff: CGFloat
do {
let iconDimensions = subviews[0].dimensions(in: proposal)
let labelDimensions = subviews[1].dimensions(in: proposal)
print(iconDimensions[.firstTextBaseline]) // 22
print(iconDimensions.height) // 29
// how to convert these to the container's coordinates?
// Simple method, just convert directly using bounds
print(labelDimensions[.firstTextBaseline]) // 16
print(labelDimensions.height) // 20
// The title or the icon can be larger, to get an accurate difference in the baselines
// we need to convert the subview's metrics into our metrics
//
// This code assumes 1 or the other view is the same height as our bounds
var labelAdjustmentToOurCoordinateSpace: CGFloat = 0
var iconAdjustmentToOurCoordinateSpace: CGFloat = 0
if iconDimensions.height >= labelDimensions.height {
// The y offset of label in our frame
let labelYOffset = (bounds.height - labelDimensions.height) / 2
labelAdjustmentToOurCoordinateSpace = labelYOffset
} else {
// The y offset of icon in our frame
//
// Rudimentary conversion assumes we're doing center alignment, so the height difference will be divided between the top and bottom
// I think there's some CGRect functions that can make this easier
let iconYOffset = (bounds.height - iconDimensions.height) / 2
iconAdjustmentToOurCoordinateSpace = iconYOffset
}
print("(\(iconDimensions[.firstTextBaseline]) + \(iconAdjustmentToOurCoordinateSpace)) - (\(labelDimensions[.firstTextBaseline]) + \(labelAdjustmentToOurCoordinateSpace))")
baselineDiff = (iconDimensions[.firstTextBaseline] + iconAdjustmentToOurCoordinateSpace) - (labelDimensions[.firstTextBaseline] + labelAdjustmentToOurCoordinateSpace)
print("baselineDiff: \(baselineDiff)")
}
var x: CGFloat = bounds.origin.x
for (index, s) in subviews.enumerated() {
let size = s.dimensions(in: proposal)
var origin = bounds.origin
origin.x = x
origin.y = bounds.midY
// Adjust the icon's y value so that the baseline is lined up
// with the text's baseline
if index == 0 {
origin.y -= baselineDiff
}
s.place(
at: origin,
anchor: .leading,
proposal: proposal
)
x += size.width
x += spacing
}
}
}
// MARK: Debugging stuff
struct DebugBorderModifier<S: ShapeStyle>: ViewModifier {
let style: S
@Environment(\.showBorders) var showBorders
func body(content: Content) -> some View {
content
.border(showBorders ? AnyShapeStyle(style) : AnyShapeStyle(.clear))
}
}
extension View {
func debugBorder(_ shapeStyle: some ShapeStyle) -> some View {
modifier(DebugBorderModifier(style: shapeStyle))
}
}
struct AlignmentLine: View {
@Environment(\.showAlignments) var showAlignments
var body: some View {
if showAlignments {
Rectangle()
.frame(height: 1)
.padding(.horizontal, -2)
}
}
}
struct Debug<Content: View>: View {
@ViewBuilder var content: Content
@Environment(\.showBorders) var showBorders
var body: some View {
ZStack(alignment: .center) {
ZStack(alignment: .centerFirstTextBaseline) {
content
.border(showBorders ? .red : .clear)
AlignmentLine()
}
AlignmentLine()
}
.debugBorder(.blue)
.fixedSize()
}
}
private struct ShowAlignmentsKey: EnvironmentKey {
static let defaultValue: Bool = true
}
extension EnvironmentValues {
var showAlignments: Bool {
get { self[ShowAlignmentsKey.self] }
set { self[ShowAlignmentsKey.self] = newValue }
}
}
private struct ShowBordersKey: EnvironmentKey {
static let defaultValue: Bool = true
}
extension EnvironmentValues {
var showBorders: Bool {
get { self[ShowBordersKey.self] }
set { self[ShowBordersKey.self] = newValue }
}
}
#Preview(nil, traits: .sizeThatFitsLayout) {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment