Created
July 21, 2024 19:50
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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