Android实战 -> 自定义ViewGroup实现足球阵容图

前言

哈喽各位好久不见,最近也是五大联赛热播期,也一直有看比赛。 那么经常看比赛的小伙伴肯定都知道足球阵容,像什么433、352、424、4231等等之类的。

那么今天这篇文章就是说去实现一下可视化的阵容图数据哈。

注:因涉及到数据隐私问题,此篇文章仅提供思路,不提供源码和数据。

开工

实现

常见阵容

3行阵容:后卫、中场、前锋

4行阵容:后卫、后腰、前腰、前锋

5行阵容:后卫、后腰、中场、前腰、前锋

业务逻辑

1、守门员位置固定

2、根据阵容摆放球员位置

实现思路

1、足球场使用一张背景图等比例计算宽高

2、每行阵容球员坐标根据背景图区分上下半场在百分比计算

3、每行阵容球员的坐标数据通过工厂抽象出来

代码

坐标代码
kotlin 复制代码
// 坐标数据接口类
interface FBILineupPosition {

    /**
     * 获取 球员Y坐标
     */
    fun getY(positionLabel: Int): Float

    /**
     * 获取 守门员Y坐标
     */
    fun getGoalkeeperY(): Float = 0.035f
}
kotlin 复制代码
// 3行阵容坐标数据实现类
class FBILineupPosition03Impl : FBILineupPosition {

    override fun getY(positionLabel: Int): Float {
        return when (positionLabel) {
            0 -> getGoalkeeperY()
            1 -> getRearGuardY()
            2 -> getMidfieldY()
            3 -> getVanguardY()
            else -> -1f
        }
    }

    /**
     * 获取 后卫Y坐标
     */
    private fun getRearGuardY(): Float = 0.235f

    /**
     * 获取 中场Y坐标
     */
    private fun getMidfieldY(): Float = 0.5f

    /**
     * 获取 前锋Y坐标
     */
    private fun getVanguardY(): Float = 0.81f
}
kotlin 复制代码
// 4行阵容坐标数据实现类
class FBILineupPosition04Impl : FBILineupPosition {

    override fun getY(positionLabel: Int): Float {
        return when (positionLabel) {
            0 -> getGoalkeeperY()
            1 -> getRearGuardY()
            2 -> getBackWaist()
            3 -> getAroundWaist()
            4 -> getVanguardY()
            else -> -1f
        }
    }

    /**
     * 获取 后卫Y坐标
     */
    private fun getRearGuardY(): Float = 0.235f

    /**
     * 获取 后腰Y坐标
     */
    private fun getBackWaist(): Float = 0.45f

    /**
     * 获取 前腰Y坐标
     */
    private fun getAroundWaist(): Float = 0.65f

    /**
     * 获取 前锋Y坐标
     */
    private fun getVanguardY(): Float = 0.81f
}
kotlin 复制代码
// 4行阵容坐标数据实现类
class FBILineupPosition05Impl : FBILineupPosition {

    override fun getY(positionLabel: Int): Float {
        return when (positionLabel) {
            0 -> getGoalkeeperY()
            1 -> getRearGuardY()
            2 -> getBackWaist()
            3 -> getMidfieldY()
            4 -> getAroundWaist()
            5 -> getVanguardY()
            else -> -1f
        }
    }

    /**
     * 获取 后卫Y坐标
     */
    private fun getRearGuardY(): Float = 0.235f

    /**
     * 获取 后腰Y坐标
     */
    private fun getBackWaist(): Float = 0.35f

    /**
     * 获取 中场Y坐标
     */
    private fun getMidfieldY(): Float = 0.5f

    /**
     * 获取 前腰Y坐标
     */
    private fun getAroundWaist(): Float = 0.75f

    /**
     * 获取 前锋Y坐标
     */
    private fun getVanguardY(): Float = 0.85f
}
球员View

这里我使用LinearLayout实现,因为可以直接平分宽度,不需要我们自己去计算了。

(这里确实是我懒了一点)

kotlin 复制代码
class FBLineupPlayerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {

    private val mData = mutableListOf<...>()

    private val mLayoutInflater by lazy { LayoutInflater.from(context) }

    init {
        this.orientation = HORIZONTAL
    }

    /**
     * 添加 数据
     */
    fun addData(itemWidth: Int, data: ...) {
        this.mData.add(data)
        // childView
        val childView = LayoutFbLineupPlayerBinding.inflate(mLayoutInflater, this, false)
        // Avatar
        Glide or Resource // 自行处理
        // Name
        childView.tvName.text = ...
        // Number
        childView.tvNumber.text = ...
        // itemWidth
        val childWidth = if (itemWidth <= 0) {
            WRAP_CONTENT
        } else {
            itemWidth
        }
        // AddView
        this.addView(childView.root, ViewGroup.LayoutParams(childWidth, WRAP_CONTENT))
    }
}
ViewGroup
kotlin 复制代码
class FBLineupView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private var tag = FBLineupView::class.java.simpleName

    private var mHalfHeight = 0f

    private var mHomeFormation: List<Char>? = null

    private var mAwayFormation: List<Char>? = null

    private val mHomePlayerViews = SparseArray<FBLineupPlayerView>()

    private val mAwayPlayerViews = SparseArray<FBLineupPlayerView>()

    private val mScreenSize by lazy { DensityUtil.getScreenSize(context) }

    private val mHomeTeamBinding by lazy {
        LayoutFbLineupTeamBinding.inflate(LayoutInflater.from(context)).also {
            this@FBLineupView.addView(it.root)
        }
    }

    private val mAwayTeamBinding by lazy {
        LayoutFbLineupTeamBinding.inflate(LayoutInflater.from(context)).also {
            this@FBLineupView.addView(it.root)
        }
    }

    private var mHomeLineupPosition: FBILineupPosition? = null

    private var mAwayLineupPosition: FBILineupPosition? = null

    private val mBackdrop: Drawable? by lazy {
        ContextCompat.getDrawable(context, R.drawable.ic_match_fb_lineup_backdrop)
    }

    init {
        this.background = mBackdrop
        // GONE
        this.visibility = View.GONE
    }

    /**
     * 设置 数据集
     *
     * @param data 比赛数据
     * @param homeFormation 主队阵型
     * @param awayFormation 客队阵型
     * @param teamData 队伍信息
     */
    fun setData(
        data: ...?,
        homeFormation: String?,
        awayFormation: String?,
        teamData: List<...>?
    ) {
        if (TextUtils.isEmpty(homeFormation) || TextUtils.isEmpty(awayFormation)) {
            this.visibility = View.GONE
            return
        }
        if (teamData.isNullOrEmpty()) {
            this.visibility = View.GONE
            return
        }
        try {
            this.visibility = View.VISIBLE
            // 处理数据
            this.handleData(data, homeFormation, awayFormation)
            // 解析阵型 格式:3-5-2 根据-进行分割计算
            if (!TextUtils.isEmpty(homeFormation)) {
                this.mHomeFormation = homeFormation?.toCharArray()?.toList()?.sorted()
                this.mHomeLineupPosition = getLineupPosition(mHomeFormation)
            }
            if (!TextUtils.isEmpty(awayFormation)) {
                this.mAwayFormation = awayFormation?.toCharArray()?.toList()?.sorted()
                this.mAwayLineupPosition = getLineupPosition(mAwayFormation)
            }
            // 主/客
            this.addPlayerViews(true, teamData, mHomeFormation, mHomePlayerViews)
            this.addPlayerViews(false, teamData, mAwayFormation, mAwayPlayerViews)
        } catch (e: Exception) {
            e.printStackTrace()
            LogUtils.d(tag, "setData() called with: Exception = ${e.message}")
            // GONE
            this.visibility = View.GONE
        }
    }

    /**
     * 处理 数据
     */
    private fun handleData(
        data: ...?, homeFormation: String?, awayFormation: String?,
    ) {
        if (data == null) {
            this.mHomeTeamBinding.root.visibility = View.GONE
            this.mAwayTeamBinding.root.visibility = View.GONE
            return
        }
        this.mHomeTeamBinding.root.visibility = View.VISIBLE
        this.mAwayTeamBinding.root.visibility = View.VISIBLE
        // 主队名称
        this.mHomeTeamBinding.tvName.text = ...
        // 主队阵型
        val finalHomeFormation = homeFormation?.replace("", "-")
            ?.replaceFirst("-", "")
            ?.trimEnd('-')
        this.mHomeTeamBinding.tvFormation.text = ...
        // 主队LOGO
        Glide or Resource // 自行处理
        // 客队名称
        this.mAwayTeamBinding.tvName.text = ...
        // 客队阵型
        val finalAwayFormation = awayFormation?.replace("", "-")
            ?.replaceFirst("-", "")
            ?.trimEnd('-')
        this.mAwayTeamBinding.tvFormation.text = ...
        // 客队LOGO
       Glide or Resource // 自行处理
    }

    /**
     * 清空 数据
     */
    fun clearData() {
        this.mHomePlayerViews.clear()
        this.mAwayPlayerViews.clear()
        this.mHomeFormation = null
        this.mAwayFormation = null
        this.mHomeLineupPosition = null
        this.mAwayLineupPosition = null
        // RemoveAllViews
        this.removeAllViews()
    }

    /**
     * 添加 成员View
     */
    private fun addPlayerViews(
        isHomeTeam: Boolean = true,
        team: List<...>?,
        formation: List<Char>?,
        cache: SparseArray<FBLineupPlayerView>
    ) {
        if (team == null || team.isEmpty()) {
            LogUtils.d(tag, "addPlayerViews() 成员数据为空哦!")
            return
        }
        val maxFormation = if (!formation.isNullOrEmpty()) {
            formation.lastOrNull()?.toString()?.toInt() ?: 0
        } else {
            0
        }
        val itemWidth = mScreenSize[0] / maxFormation
        // forEach
        team.forEach { itemData ->
            val playerData = if (isHomeTeam) {
                itemData.homePlayerList
            } else {
                itemData.awayPlayerList
            }
            if (playerData != null) {
                val key = (playerData.positionLabel ?: -1)
                // Container
                val cacheContainer = cache.get(key)
                if (cacheContainer == null) {
                    val playerView = FBLineupPlayerView(context).also {
                        it.addData(itemWidth, playerData)
                        cache.put(key, it)
                    }
                    this.addView(playerView, LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
                } else {
                    cacheContainer.addData(itemWidth, playerData)
                }
            }
        }
    }

    /**
     * 获取 球员坐标
     *
     * @param formation 阵型
     *
     * 出场的行数:
     * 3行:守门员,后卫,中场,前锋
     * 4行:守门员,后卫,后腰,前腰,前锋
     * 5行:守门员,后卫,后腰,中场,前腰,前锋
     */
    private fun getLineupPosition(formation: List<Char>?): FBILineupPosition? {
        return when (formation?.size) {
            3 -> FBILineupPosition03Impl()
            4 -> FBILineupPosition04Impl()
            5 -> FBILineupPosition05Impl()
            else -> null
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 测量所有子View
        this.measureChildren(widthMeasureSpec, heightMeasureSpec)
        // 屏幕高度
        val screenWidth = mScreenSize[0]
        // 等比例背景宽高
        var wRatio = screenWidth / (mBackdrop?.intrinsicWidth?.toFloat() ?: 1f)
        if (wRatio == 0f) wRatio = 1f
        val height = (mBackdrop?.intrinsicHeight ?: 1) * wRatio
        // 计算半场高度
        this.mHalfHeight = height / 2f
        // 总高度
        this.setMeasuredDimension(screenWidth, height.toInt())
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        // 主队
        this.onLayoutHome(right)
        // 客队
        this.onLayoutAway(right)
    }

    /**
     * 主队
     */
    private fun onLayoutHome(parentRight: Int) {
        try {
            // Team
            val offset = resources.getDimensionPixelSize(R.dimen.dp_16)
            val teamWidth = mHomeTeamBinding.root.measuredWidth
            val teamHeight = mHomeTeamBinding.root.measuredHeight
            // 定位位置
            this.mHomeTeamBinding.root.layout(
                offset, offset, (parentRight - teamWidth), (offset + teamHeight)
            )
            // Player
            this.mHomePlayerViews.forEach { index, itemView ->
                // 根据位置标签获取坐标位置
                val y = mHomeLineupPosition?.getY(index) ?: -1f
                // 校验
                if (y > -1f) {
                    // ItemView 宽高
                    val width = itemView.measuredWidth
                    val height = itemView.measuredHeight
                    // ItemView 矩形区域的左、上、右、下
                    val left = ((parentRight / 2) - (width / 2))
                    val top = (mHalfHeight * y).toInt()
                    val right = (parentRight - left)
                    val bottom = (top + height)
                    // 定位位置
                    itemView.layout(left, top, right, bottom)
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            LogUtils.d(tag, "onLayoutHome() called with: Exception = ${e.message}")
        }
    }

    /**
     * 客队
     */
    private fun onLayoutAway(parentRight: Int) {
        try {
            // Team
            val offset = resources.getDimensionPixelSize(R.dimen.dp_16)
            val teamWidth = mAwayTeamBinding.root.measuredWidth
            val teamHeight = mAwayTeamBinding.root.measuredHeight
            val top = (height - offset - teamHeight)
            // 定位位置
            this.mAwayTeamBinding.root.layout(
                offset, top, (parentRight - teamWidth), (top + teamHeight)
            )
            // Player
            this.mAwayPlayerViews.forEach { index, itemView ->
                // 根据位置标签获取坐标位置
                val y = 1 - (mAwayLineupPosition?.getY(index) ?: -1f)
                // 校验
                if (y > -1f) {
                    itemView.visibility = View.VISIBLE
                    // ItemView 宽高
                    val width = itemView.measuredWidth
                    val height = itemView.measuredHeight
                    // ItemView 矩形区域的左、上、右、下
                    val left = ((parentRight / 2) - (width / 2))
                    val bottom = (mHalfHeight + (mHalfHeight * y)).toInt()
                    val right = (parentRight - left)
                    val top = (bottom - height)
                    // 定位位置
                    itemView.layout(left, top, right, bottom)
                } else {
                    itemView.visibility = View.GONE
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            LogUtils.d(tag, "onLayoutAway() called with: Exception = ${e.message}")
        }
    }
}

其实也很简单,就是利用ViewGroup的onLayout方法,然后根据百分比定位一下球员的Y坐标即可

再放几张效果图
3行阵容 4行阵容 5行阵容
-我还没见到过-

到这里就结束了,其实这篇文章只是同样的体育爱好者提供一个实现思路,当然如果有更好的实现方式或方案也希望各位在评论区留言讨论,我秒回复哦~ Bye

相关推荐
七夜zippoe10 分钟前
前端开发中的难题及解决方案
前端·问题
红橙Darren1 小时前
手写操作系统 - 环境搭建
android·微信·操作系统
Hockor1 小时前
用 Kimi K2 写前端是一种什么体验?还支持 Claude Code 接入?
前端
杨进军1 小时前
React 实现 useMemo
前端·react.js·前端框架
海底火旺1 小时前
浏览器渲染全过程解析
前端·javascript·浏览器
_一条咸鱼_1 小时前
Android Runtime直接内存管理原理深度剖析(73)
android·面试·android jetpack
你听得到111 小时前
揭秘Flutter图片编辑器核心技术:从状态驱动架构到高保真图像处理
android·前端·flutter
wilinz1 小时前
Flutter Android 端接入百度地图踩坑记录
android·flutter
驴肉板烧凤梨牛肉堡1 小时前
浏览器是否支持webp图像的判断
前端
Xi-Xu1 小时前
隆重介绍 Xget for Chrome:您的终极下载加速器
前端·网络·chrome·经验分享·github