You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

RTL语言下SwiftUI InfiniteCarouselView无法滑动问题求助

SwiftUI InfiniteCarouselView RTL环境滑动异常修复方案

我开发的SwiftUI InfiniteCarouselView在LTR(左到右)语言环境(如英语)中运行正常,但切换到RTL(右到左)语言环境(如阿拉伯语)后,无法正常实现无限滑动效果。以下是相关代码及修复方案:

问题代码

InfiniteCarouselView.swift

//  InfiniteCarouselView.swift
//  Lose Weight in 30 Days
//
//  Created by MacBook on 08/01/2025.
//

import SwiftUI

// Model
struct Page: Identifiable, Hashable {
    var id: UUID = .init()
    var plan: Plan
}

// Helpers
struct OffsetKey: PreferenceKey {
    static var defaultValue: CGRect = .zero
    
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

// Extentions
extension View {
    @ViewBuilder
    func offsetX(_ addObserver: Bool, completion: @escaping (CGRect) -> ()) -> some View {
        self
            .frame(maxWidth: .infinity)
            .overlay {
                if addObserver {
                    GeometryReader {
                        let rect = $0.frame(in: .global)
                        
                        Color.clear
                            .preference(key: OffsetKey.self, value: rect)
                            .onPreferenceChange(OffsetKey.self, perform: completion)
                    }
                }
            }
    }
}

// Views
struct PageControl: UIViewRepresentable {
    /// Page Properties
    var totalPages: Int
    var currentPage: Int
    
    func makeUIView(context: Context) -> UIPageControl {
        let control = UIPageControl()
        control.numberOfPages = totalPages
        control.currentPage = currentPage
        
        // Set the selected and default dot colors
        control.currentPageIndicatorTintColor = UIColor(Color.accent)
        
        control.backgroundStyle = .prominent
        control.allowsContinuousInteraction = false
        
        return control
    }
    
    func updateUIView(_ uiView: UIPageControl, context: Context) {
        uiView.numberOfPages = totalPages
        uiView.currentPage = currentPage
    }
}

struct InfiniteCarouselView: View {
    /// View Properties
    @State private var currentPage: String = ""
    @State private var listOfPages: [Page] = []
    /// Infinite Carousel Properties
    @State private var fakedPages: [Page] = []
    
    @State private var isNavigating: Bool = false // Added flag to handle navigation state
    
    var body: some View {
        VStack {
            GeometryReader {
                let size = $0.size
                
                VStack {
                    TabView(selection: $currentPage, content: {
                        ForEach(fakedPages) { page in
                            ZStack {
                                PlanMainBox(plan: page.plan)
                                    .onTapGesture {
                                        handleTap(for: page.id)
                                    }
                            }
                            .padding(.horizontal, 10)
                            .frame(height: size.height)
                            .tag(page.id.uuidString)
                        /// Calculating Entire Page Scroll Offset
                            .offsetX(currentPage == page.id.uuidString) { rect in
                                let minX = rect.minX
                                let pageOffset = minX - (size.width * CGFloat(fakedIndex(page)))
                                /// Converting Page Offset into Progress
                                let pageProgress = pageOffset / size.width
                                /// Infinite Carousel Logic
                                if -pageProgress < 1.0 {
                                    if fakedPages.indices.contains(fakedPages.count - 1) {
                                        currentPage = fakedPages[fakedPages.count - 1].id.uuidString
                                    }
                                }
                                
                                if -pageProgress > CGFloat(fakedPages.count - 1) {
                                    if fakedPages.indices.contains(1) {
                                        currentPage = fakedPages[1].id.uuidString
                                    }
                                }
                                
                            }
                        }
                    })
                    .tabViewStyle(.page(indexDisplayMode: .never))
                }
            }
            .frame(height: 350)
            .onAppear {
                guard fakedPages.isEmpty else { return }
                for plan in Plan.allCases {
                    listOfPages.append(.init(plan: plan))
                }
                
                fakedPages.append(contentsOf: listOfPages)
                
                if var firstpage = listOfPages.first, var lastPage = listOfPages.last {
                    currentPage = firstpage.id.uuidString
                    
                    firstpage.id = .init()
                    lastPage.id = .init()
                    
                    fakedPages.append(firstpage)
                    fakedPages.insert(lastPage, at: 0)
                }
            }
            
            PageControl(totalPages: listOfPages.count, currentPage: originalIndex(currentPage))
                .disabled(true)
            
            if listOfPages.indices.contains(originalIndex(currentPage)) {
                NavigationLink(
                    destination: PlanView(plan: listOfPages[originalIndex(currentPage)].plan),
                    isActive: $isNavigating
                ) {
                    EmptyView()
                }
                .hidden()
            }
        }
    }
    
    private func handleTap(for id: UUID) {
        isNavigating = true
    }
    
    func fakedIndex(_ of: Page) -> Int {
        return fakedPages.firstIndex(of: of) ?? 0
    }
    
    func originalIndex(_ id: String) -> Int {
        return listOfPages.firstIndex { page in
            page.id.uuidString == id
        } ?? 0
    }
}

#Preview {
    InfiniteCarouselView()
}

PlanMainBox.swift

//  PlanMainBox.swift
//  Plank Workout
//
//  Created by MacBook on 26/12/2024.
//

import SwiftUI

struct PlanMainBox: View {
    
    @Environment(\.layoutDirection) var layoutDirection // Detect layout direction
    
    var plan: Plan
    
    var body: some View {
        ZStack(alignment: .leading) {
            Image(plan.bgImage)
                .resizable()
                .scaleEffect(x: layoutDirection == .rightToLeft ? -1 : 1, y: 1) // Flip horizontally for RTL
                .cornerRadius(20)
            
            VStack(alignment: .leading) {
                
                _Text(plan.title, size: 25, type: .bold)
                    .foregroundColor(.white)
                    .padding(.top, 10)
                
                _Text(plan.subTitle, size: 18, type: .bold)
                    .foregroundColor(Color(hex: "#808082"))
                    .frame(width: 200, alignment: .leading)
                
                _Text("WORKOUT", size: 25, type: .bold)
                    .foregroundColor(Color(hex: "#808082"))
                    .frame(width: 200, alignment: .leading)
                
                HStack {
                    Image(.fireIcon)
                    Image(.fireIcon)
                        .opacity(plan.level >= 2 ? 1 : 0.2) // 20% opacity
                    Image(.fireIcon)
                        .opacity(plan.level == 3 ? 1 : 0.2) // 20% opacity
                }
                
                _Text("START YOUR PLAN NOW !", size: 11, type: .medium)
                    .foregroundColor(Color(hex: "#808082"))
                
                Spacer()
                
                Image(.playIcon)
                    .resizable()
                    .scaleEffect(x: layoutDirection == .rightToLeft ? -1 : 1, y: 1) // Flip horizontally for RTL
                    .aspectRatio(contentMode: .fit)
                    .padding(20)
                    .background(Color.accentColor)
                    .clipShape(Circle())
                    .frame(width: 60, height: 60)
            }
            .padding(15)
        }
        .frame(height: 350)
    }
}

#Preview {
    MainTabView()
        .environmentObject(ProfileViewModel())
}

问题根源

无限滚动的核心逻辑依赖于视图的minX偏移计算,但在RTL布局下,系统坐标系统方向反转,导致原本的偏移计算和边界判断完全失效:

  • LTR环境中,页面从左向右滑动时minX值增大
  • RTL环境中,页面从右向左滑动时minX值变化方向相反,原有的pageProgress计算逻辑不再适用

修复方案

修改InfiniteCarouselView中的偏移计算逻辑,适配RTL布局:

1. 更新offsetX扩展,传递布局方向

extension View {
    @ViewBuilder
    func offsetX(_ addObserver: Bool, layoutDirection: LayoutDirection, completion: @escaping (CGRect, LayoutDirection) -> ()) -> some View {
        self
            .frame(maxWidth: .infinity)
            .overlay {
                if addObserver {
                    GeometryReader {
                        let rect = $0.frame(in: .global)
                        
                        Color.clear
                            .preference(key: OffsetKey.self, value: rect)
                            .onPreferenceChange(OffsetKey.self) { rect in
                                completion(rect, layoutDirection)
                            }
                    }
                }
            }
    }
}

2. 调整无限滚动核心逻辑,适配RTL

struct InfiniteCarouselView: View {
    // 原有属性保持不变
    
    var body: some View {
        VStack {
            GeometryReader { proxy in
                let size = proxy.size
                @Environment(\.layoutDirection) var layoutDirection
                
                VStack {
                    TabView(selection: $currentPage, content: {
                        ForEach(fakedPages) { page in
                            ZStack {
                                PlanMainBox(plan: page.plan)
                                    .onTapGesture {
                                        handleTap(for: page.id)
                                    }
                            }
                            .padding(.horizontal, 10)
                            .frame(height: size.height)
                            .tag(page.id.uuidString)
                            .offsetX(currentPage == page.id.uuidString, layoutDirection: layoutDirection) { rect, direction in
                                let minX = rect.minX
                                var pageOffset: CGFloat
                                var pageProgress: CGFloat
                                
                                // 根据布局方向调整偏移计算
                                if direction == .rightToLeft {
                                    pageOffset = (size.width * CGFloat(fakedPages.count - 1 - fakedIndex(page))) - minX
                                    pageProgress = pageOffset / size.width
                                } else {
                                    pageOffset = minX - (size.width * CGFloat(fakedIndex(page)))
                                    pageProgress = pageOffset / size.width
                                }
                                
                                // 适配RTL的无限滚动边界判断
                                if direction == .rightToLeft {
                                    if pageProgress < 1.0 {
                                        if fakedPages.indices.contains(1) {
                                            currentPage = fakedPages[1].id.uuidString
                                        }
                                    }
                                    
                                    if pageProgress > CGFloat(fakedPages.count - 1) {
                                        if fakedPages.indices.contains(fakedPages.count - 1) {
                                            currentPage = fakedPages[fakedPages.count - 1].id.uuidString
                                        }
                                    }
                                } else {
                                    if -pageProgress < 1.0 {
                                        if fakedPages.indices.contains(fakedPages.count - 1) {
                                            currentPage = fakedPages[fakedPages.count - 1].id.uuidString
                                        }
                                    }
                                    
                                    if -pageProgress > CGFloat(fakedPages.count - 1) {
                                        if fakedPages.indices.contains(1) {
                                            currentPage = fakedPages[1].id.uuidString
                                        }
                                    }
                                }
                            }
                        }
                    })
                    .tabViewStyle(.page(indexDisplayMode: .never))
                }
            }
            .frame(height: 350)
            // 原有onAppear、PageControl、NavigationLink逻辑保持不变
        }
    }
    
    // 原有方法保持不变
}

额外说明

  • PlanMainBox中的图片翻转逻辑正确,无需修改,确保了RTL下图片内容显示方向正常
  • PageControl使用系统UIPageControl,会自动适配RTL布局,无需额外调整

内容的提问来源于stack exchange,提问作者Abdul Rehman

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 22:05:54