@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) struct CenteredLoadingText: View { private let mainText: String private let dots = [".", ".", "."] private let repeatInterval: TimeInterval private let accessibilityLabel: String private let loadingAnimation: Animation private let spacing: CGFloat /// https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer private let timer: Publishers.Autoconnect @State private var trailingText = "" @State private var currentIndex = 0 init( centerText: String, accessibilityLabel: String, repeatInterval: Double = 0.5, loadingAnimation: Animation = Animation.default, spacing: CGFloat = 0) { self.mainText = centerText self.accessibilityLabel = accessibilityLabel self.repeatInterval = repeatInterval self.loadingAnimation = loadingAnimation self.spacing = spacing timer = Timer.publish(every: repeatInterval, on: .main, in: .default).autoconnect() } var body: some View { FirstChildrenCenteredLayout(spacing: spacing) { Text(mainText) .border(.black) Text(trailingText) .border(.red) } .onReceive(timer) { _ in withAnimation(loadingAnimation) { if currentIndex < dots.count { trailingText += dots[currentIndex] currentIndex += 1 } else { currentIndex = 0 trailingText = "" } } } .onDisappear { timer.upstream.connect().cancel() } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) } private struct FirstChildrenCenteredLayout: Layout { // MARK: Lifecycle init(spacing: CGFloat) { self.spacing = spacing } // MARK: Public public func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) -> CGSize { // Return a size. assert(subviews.count == 2) let width = proposal.width ?? 0 // We assume texts has the same Font and number of lines is 1, so the first height should be enough. let height = subviews.first?.sizeThatFits(proposal).height ?? 0 return CGSize(width: width, height: height) } public func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) { // Place child views. assert(subviews.count == 2) let height = subviews.first?.sizeThatFits(proposal).height ?? 0 let combinedWidth = subviews.map { $0.sizeThatFits(proposal) }.reduce(0.0) { (result, size) in return result + size.width } let placementProposal = ProposedViewSize(width: combinedWidth, height: height) let firstSubView = subviews[0] let firstSubViewSize = firstSubView.sizeThatFits(proposal) let firstSubViewPoint = CGPoint(x: bounds.midX, y: bounds.midY) firstSubView.place(at: firstSubViewPoint, anchor: .center, proposal: placementProposal) let secondSubView = subviews[1] let secondSubViewPoint = CGPoint(x: firstSubViewPoint.x + firstSubViewSize.width / 2 + spacing, y: bounds.midY - firstSubView.sizeThatFits(proposal).height / 2) secondSubView.place(at: secondSubViewPoint, proposal: placementProposal) } // MARK: Private private let spacing: CGFloat } } struct ContentView: View { var body: some View { CenteredLoadingText( centerText: "Loading", accessibilityLabel: "Loading") } } #Preview { ContentView() }