通常情况下,显示一个 3D 模型,只要有对应的资源就可以实现了,但是这个仅仅是通常情况,肯定会有特殊情况的,这不刚好憋了好长时间,需要憋出一个特殊情况的大招;实现一个规则锥形多面体,不过在此基础上支持配置成圆柱形规则多面体
先看看效果图
多面体实现思路
看起来是一个很复杂的东西,不过拆解出来后,思路就会变得比较简单了
先确定一个加载点的位置,然后加载若干个面,每个面都是相对于加载点的位置显示,根据一定规则,确定每一个面的尺寸大小,最后旋转加载点,就完成了上述的效果
多面体类
多面体的配置文件,是实现的关键,内部会支持设置行、列、宽、高、递减模式、递减比例
// 多面体的配置
class ObjCylinderConfig(y : Float) : ObjConfig() {
var ifDebug = false //是否开启调试模式
val DEFAULT_ROW = 3 //常量-默认行数
val DEFAULT_COLUMN = 15 //常量-默认列数
var context : Context? = null
// 默认宽度 1 米
// 开启递减模式,最大圆所在的行半径为 0.5 米
// 未开启递减模式,所有行的圆都是 0.5 米
var y = 1f
get() = field
set(value) {
field = value
}
var widthScaleHeight : Float = 3f/4 // 单元格的宽高比(宽屏 3:4)
var checkSizeScale : Float = 1f // 单元格的矫正比例
var rowDecreaseScale = 0.9f // 每一行的尺寸缩小比例,从中间行开始
var ifRowViewDecreaseScale = true // 每行尺寸是否缩小的开关
var checkRowSpace = 0f; // 用于调整行间距,根据情况自行设置
var checkRowScale = 3/7f; // 用于调整行间距比例,避免每行行间距过宽,存在逐行递减,所以需要动态根据比例计算
var objCylinderAdapter : ObjCylinderAdapter? = null // 网格内容适配器
var tmpModel : ModelRenderable? = null // 用于标识中心点的调试点
var debugPointExtent = 0.02f // 调试点是一个球体,代表该球体的半径
private var nodeSet : MutableList<MutableList<Node>> = ArrayList() //管理多面体所有的节点集合
var listener : OnItemClickListener? = null // 点击事件监听器
var row = DEFAULT_ROW // 多面体的行数
get() = field
set(value) {
if (value <= 0) {
field = DEFAULT_ROW
} else {
field = value
}
}
var column = DEFAULT_COLUMN // 多面体的列数
get() = field
set(value) {
if (value <= 0) {
field = DEFAULT_COLUMN
} else {
field = value
}
}
// 对应列数的角度
fun getCellRowAngle() : Float {
return 360f / column
}
// 根据列数,计算最大半径所在的行,较为合适的网格宽度,可设置
var itemWidth = Math.abs(Math.sin(getCellRowAngle().toDouble() / 2).toFloat()) * y/2 * checkSizeScale * 5f/3
// 根据宽高比,计算最大半径所在的行,较为合适的高度,可以设置宽高比调整
var itemHeight = itemWidth * widthScaleHeight
// 构造函数
init {
this.y = y
}
// 构造函数
constructor() : this(1f) {
}
// 加载调试模式下球体的纹理资源
fun loadDebugPointModel(resId : Int) {
Texture.builder().setSource(context, resId).build()
.thenAccept(
{ texture ->
MaterialFactory.makeOpaqueWithTexture(context, texture)
.thenAccept { material ->
tmpModel = ShapeFactory.makeSphere(debugPointExtent, Vector3(0f, 0f, 0f), material)
tmpModel!!.isShadowCaster = false
}
}
)
}
// 构建所有基于父节点的面,从而形成多面体
fun loadAllFace(parent : Node) {
// 没有行,不加载任何一个面
if (row == 0) {
return
}
nodeSet.clear()
// 找到在当前总行数下,中间行的 ID,中间行可能是一行,也可能是时两行
var centreRow : MutableList<Int> = findCenterRow()
// 根据行数,逐行绘制每一行所有的面
for (i in 0 .. (row - 1)) {
// 当前行所有面所在节点的集合
var tmpList : MutableList<Node> = ArrayList()
var rowCellSizeScale = caculateCurrentRowSizeScaleInfo(i, centreRow)
var checkHeight = caculateCurrentRowCheckHeight(i, centreRow)
var checkAxisX = caculateCurrentRowAxisX(i, centreRow)
var center = caculateLookAtPostion(checkAxisX, checkHeight, i, centreRow)
// 逐个构造当前行所有的节点
for (j in 0 .. (column - 1)) {
var node = Node()
node.setParent(parent)
// 根据当前节点的序号,以及偏转角度,计算当前节点所在位置
var pos = Vector3(checkAxisX, 0f + checkHeight, 0f)
val rowRot = Vector3(0f, getCellRowAngle() * j, 0f)
val rowRotQuaternion = Quaternion.eulerAngles(rowRot)
val showPos = Quaternion.rotateVector(rowRotQuaternion, pos)
// 为当前节点加载平面模型
ViewRenderable.builder()
.setView(context, objCylinderAdapter!!.layoutId)
.setSizer(DpToMetersViewSizer(ParamKey.DPPERMETER)) // 设置尺寸比例
.build()
.thenAccept { it ->
it.isShadowCaster = false
// 通过适配器,获取加载了显示内容的模型
if (objCylinderAdapter != null) {
var tmp = it//objCylinderAdapter!!.getCellViewRenderable(i, j)
// 控制模型中心点的位置是节点,如果不设置,中心点会在模型的底部
tmp.horizontalAlignment = ViewRenderable.HorizontalAlignment.CENTER
tmp.verticalAlignment = ViewRenderable.VerticalAlignment.CENTER
// 设置模型的初始尺寸
tmp.view.layoutParams.height = (itemHeight * ParamKey.DPPERMETER).toInt()
tmp.view.layoutParams.width = (itemWidth * ParamKey.DPPERMETER).toInt()
tmp = objCylinderAdapter!!.getCellViewRenderable(tmp, i, j)
node.renderable = tmp
}
// 设置显示的相对位置
node.localPosition = showPos
// 根据比例和模型的初始尺寸,设置模型实际的大小
node.localScale = Vector3(rowCellSizeScale, rowCellSizeScale, 1f)
// 根据中心点,设置模型朝向的方向
val direction = Vector3.subtract(center, node.localPosition)
node.localRotation = Quaternion.lookRotation(direction, Vector3.up())
// 向调用方提供的点击事件的监听器
node.setOnTapListener(object : Node.OnTapListener {
override fun onTap(p0: HitTestResult?, p1: MotionEvent?) {
if (listener != null) {
listener!!.onItemClicked(i , j)
}
}
})
tmpList.add(node)
}
if (ifDebug && tmpModel != null) { // 开启 debug 模式的时候显示中心点的位置
var nodeDebug = Node()
nodeDebug.setParent(parent)
nodeDebug.localPosition = showPos
nodeDebug.renderable = tmpModel
}
}
nodeSet.add(tmpList)
}
}
// 每一行的倾角是通过看向指定点的构造出来的
// 计算每一行对应的朝向的点
// xLong 每一行的半径
// checkHeight 每一行所在的高度
// currentRowNo 当前行号
// centreRow 中间行的集合
private fun caculateLookAtPostion(xLong : Float, checkHeight : Float, currentRowNo : Int, centreRow : MutableList<Int>) : Vector3 {
if (ifRowViewDecreaseScale) { // 开启递减模式
var finalAngel = 0f // 初始的倾斜角度
var decreaseFoot = 90 * (1 - rowDecreaseScale) // 每一行的倾角度数
var finalCenterHeight = checkHeight
if (currentRowNo > centreRow.last()) {
finalAngel = decreaseFoot
} else if (currentRowNo < centreRow[0]) {
finalAngel = decreaseFoot * -1
}
// 根据朝向角度,计算朝向点的位置
finalCenterHeight += Math.tan(finalAngel.toDouble()).toFloat() * xLong
return Vector3(0f, 0f + finalCenterHeight, 0f)
} else {
return Vector3(0f, 0f + checkHeight, 0f)
}
}
// 找到中间行的行号,可能中间行有两行,可能只有一行
private fun findCenterRow() : MutableList<Int> {
var centreRow : MutableList<Int> = ArrayList()
// 没有唯一中间行
if (row % 2 == 0) {
row / 2
centreRow.add(row / 2 - 1)
centreRow.add(row / 2)
} else { // 存在唯一中建行
centreRow.add(row / 2)
}
return centreRow
}
// 计算当前行的半径
private fun caculateCurrentRowAxisX(currentRowNo : Int, centreRow : MutableList<Int>) : Float {
var xAxisFoot = y / 2 // 最大半径
if (ifRowViewDecreaseScale) { // 开启递减模式
var ret = 0f;
if (centreRow.contains(currentRowNo)) { // 当前行是中间行
return xAxisFoot * (rowDecreaseScale + 0.05f) // 微调最大行的半径
// return xAxisFoot // 可直接返回最大行的半径,根据实际情况调整
} else if (currentRowNo > centreRow.last()) { // 非中间行,半径按比例指数缩小
ret = xAxisFoot * Math.pow(rowDecreaseScale.toDouble(), (currentRowNo - centreRow.last()).toDouble()).toFloat()
} else if (currentRowNo < centreRow[0]) { // 非中间行,半径按比例指数缩小
ret = xAxisFoot * Math.pow(rowDecreaseScale.toDouble(), (centreRow[0] - currentRowNo).toDouble()).toFloat()
}
return ret
} else {
return xAxisFoot // 未开启递减模式,直接返回最大半径
}
}
// 计算当前行的行高
private fun caculateCurrentRowCheckHeight(currentRowNo : Int, centreRow : MutableList<Int>) : Float {
if (!ifRowViewDecreaseScale) { //未开启递减模式
var ret = 0f;
var yAxisFoot = itemHeight * checkRowScale + checkRowSpace
if (centreRow.size == 2) { // 双数行,真正中间位置调整
if (currentRowNo >= centreRow.last()) {
ret = (currentRowNo - centreRow.last()) * yAxisFoot
} else {
ret = (currentRowNo - centreRow[0] - 1) * yAxisFoot
}
} else { //单数行,中间行不需要调整
ret = (currentRowNo - centreRow[0]) * yAxisFoot
}
return ret
} else { // 开启递减模式
// 计算当前行与中间行的行差
var tmp = 0
if (centreRow.contains(currentRowNo)) {
if (centreRow.size == 2) { // 中间行有两行的时候,以行数大的行作为基准行
return (currentRowNo - centreRow.last()) * itemHeight * checkRowScale
} else {
return 0f;
}
} else if (currentRowNo < centreRow[0]) {
tmp = currentRowNo - centreRow[0]
} else {
tmp = currentRowNo - centreRow.last()
}
// 根据行差,计算每一行的高度,每一行的比例都已正数计算,后续会对对称行的正数高度做修正
var collectRet = 0f
for (j in 0 .. (Math.abs(tmp) - 1)) {
collectRet += itemHeight * checkRowScale * Math.pow(rowDecreaseScale.toDouble(), j.toDouble()).toFloat() + checkRowSpace
}
// 对以中间行对称行号的高度,取负数修正
if (tmp < 0) {
collectRet *= -1
if (centreRow.size == 2) {
collectRet -= itemHeight * checkRowScale
}
}
return collectRet
}
}
// 根据是否尺寸递减开关是否打开,计算每一行单元格的尺寸
private fun caculateCurrentRowSizeInfo(currentRowNo : Int, centreRow : MutableList<Int>) : RowCellSize {
var ret = RowCellSize(itemHeight, itemWidth)
if (ifRowViewDecreaseScale) { // 尺寸递减开关打开
var times = caculateCurrentRowSizeScaleInfo(currentRowNo, centreRow)
ret.cellItemHeight *= times
ret.cellItemWidth *= times
}
return ret
}
// 计算当前行网格元素显示的比例
private fun caculateCurrentRowSizeScaleInfo(currentRowNo : Int, centreRow : MutableList<Int>) : Float {
var times = 1f
if (ifRowViewDecreaseScale) { // 开启尺寸递减
// 除过中间行,向向下均已 0.9 的比例缩小
if (currentRowNo < centreRow[0]) {
times = Math.pow((rowDecreaseScale).toDouble(), (centreRow[0] - currentRowNo).toDouble()).toFloat()
} else if (currentRowNo > centreRow.last()) {
times = Math.pow((rowDecreaseScale).toDouble(), (currentRowNo - centreRow.last()).toDouble()).toFloat()
}
}
return times
}
data class RowCellSize(var cellItemHeight : Float, var cellItemWidth : Float)
}
圆主体配置类依赖的 ObjConfig
// ObjCylinderConfig 的基类
// 在本 demo 中没有使用,可以去除
open class ObjConfig (){
var color : Color = Color(255f, 255f, 255f, 255f)
get() = field
set(value) {
field = value
}
init {
color = Color(255f, 255f, 255f, 255f)
}
}
点击事件的监听器
interface OnItemClickListener {
fun onItemClicked(row : Int, column : Int)
}
多面体数据内容适配器
效果图里可以看到,在其中一个网格内有图片显示,该图片是通过适配器,加载进模型的,适配器的实现如下
/**
* 网格元素内容内容填充适配器
*/
class ObjCylinderAdapter(content : MutableMap<String, ItemContent>) {
// 网格元素使用的布局
// 当前的布局只有一个 ImageView
var layoutId = R.layout.cylinder_item_layout
// 网格元素显示的数据内容集合
// 当前使用的 key 为 Postion 做了 JSON 处理的字符串,可根据实际情况自行处理
// value 为要显示的数据内容
var content : MutableMap<String, ItemContent> = HashMap()
get() = field
set(value) {
if (value == null) {
field = HashMap()
} else {
field = value
}
}
init {
this.content = content
}
// 为每一个网格元素添加内容
fun getCellViewRenderable(viewRenderable : ViewRenderable, row : Int, column : Int) : ViewRenderable{
var tmp = viewRenderable
// 当对应的网格位置存在数据内容的时候,为 ImageView 加载图片资源
var itemContent = content.get(JSON.toJSONString(Postion(row, column)))
var img = tmp.view.img as ImageView
if (itemContent != null) {
img.setImageResource(itemContent.image!!)
}
return tmp
}
}
content 中的位置信息 String 是 Postion JSON 后的字符串, ItemContent 是每一个网格对应数据内容
// 包含每一格网格要显示的数据内容
class ItemContent {
// 图片资源 ID,该属性可以根据实际情况做处理
// 仅与适配器 ObjCylinderAdapter 相关联
var image : Int? = null
// 当前元素要显示的位置,对于本 demo 来说可以省略
var pos : Postion = Postion();
}
class Postion(row : Int, column : Int) {
// 行号
var row : Int? = null
// 列号
var column : Int? = null
init {
this.row = row
this.column = column
}
constructor() : this(0, 0) {
}
}
主界面加载多面体模型
主界面加载模型就比较简单了,只是设置一下属性,同时让旋转节点开始旋转
class MainActivity : AppCompatActivity() {
private val TAG = "XXX"
private var arFragment: CleanArFragment? = null
private val EXTENTX_CYLINDER = 0.5f // 椭圆主体的宽度
var config : ObjCylinderConfig = ObjCylinderConfig()
get() = field
set(value) {
if (value == null) {
field = ObjCylinderConfig();
} else {
field = value
}
}
// 初始化显示的数据
private fun initContent2Show() : MutableMap<String, ItemContent> {
val content = HashMap<String, ItemContent>()
val showEle = ItemContent()
showEle.image = R.drawable.ic_launcher
showEle.pos = Postion(0, 0)
content.put(JSON.toJSONString(showEle.pos), showEle)
return content
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
arFragment = supportFragmentManager.findFragmentById(R.id.ux_fragment) as CleanArFragment?
// arFragment!!.getArSceneView().getPlaneRenderer().setVisible(false)
config = ObjCylinderConfig(EXTENTX_CYLINDER) // 设置最大行半径
config.objCylinderAdapter = ObjCylinderAdapter(initContent2Show()) // 设置适配器
config.context = this@MainActivity
config.ifRowViewDecreaseScale = false
config.ifDebug = false // 关闭调试模式
config.loadDebugPointModel(R.drawable.final_point) // 设置调试模式的中心点的纹理
config.widthScaleHeight = 1f // 设置每一行的宽高比
config.listener = listener // 添加点击的监听器
arFragment!!.setOnTapArPlaneListener { hitResult: HitResult, plane: Plane, motionEvent: MotionEvent ->
// Create the Anchor.
val anchor = hitResult.createAnchor()
val anchorNode = AnchorNode(anchor)
// 为当前节点添加所有面
val showNode = RotatingNode()
showNode.worldPosition = anchorNode.worldPosition
showNode.setParent(arFragment!!.arSceneView.scene)
config.loadAllFace(showNode)
// 开始旋转
showNode.startAnimation()
}
}
var listener = object : OnItemClickListener {
override fun onItemClicked(row: Int, column: Int) {
Log.e(TAG, "row=" + row + ", column=" + column)
}
}
}
旋转节点是中心节点使用了旋转动画
/**
* 旋转节点的实现参照了 https://www.jianshu.com/p/f058bf833af6
*/
class RotatingNode : Node() {
var rotationAnimation: ObjectAnimator? = null
var degreesPerSecond = 5.0f
val speedMultiplier = 1.0f
val animationDuration = (1000 * 360 / (degreesPerSecond * speedMultiplier)).toLong()
// 启动动画
fun startAnimation() {
if (rotationAnimation != null) {
return
}
rotationAnimation = createAnimator()
rotationAnimation!!.target = this
rotationAnimation!!.duration = animationDuration
rotationAnimation!!.start()
}
// 停止动画
fun stopAnimation() {
if (rotationAnimation == null) {
return
}
rotationAnimation!!.cancel()
rotationAnimation = null
}
// 返回一个 ObjectAnimator 用来使节点旋转起来
private fun createAnimator(): ObjectAnimator {
// 节点的位置和角度信息设置通过Quaternion来设置
// 创建4个Quaternion 来设置四个关键位置
val orientation1 = Quaternion.axisAngle(Vector3(0.0f, 1.0f, 0.0f), 0f)
val orientation2 = Quaternion.axisAngle(Vector3(0.0f, 1.0f, 0.0f), 120f)
val orientation3 = Quaternion.axisAngle(Vector3(0.0f, 1.0f, 0.0f), 240f)
val orientation4 = Quaternion.axisAngle(Vector3(0.0f, 1.0f, 0.0f), 360f)
val rotationAnimation = ObjectAnimator()
rotationAnimation.setObjectValues(orientation1, orientation2, orientation3, orientation4)
// 设置属性动画修改的属性为 localRotation
rotationAnimation.setPropertyName("localRotation")
// 使用Sceneform 框架提供的估值器 QuaternionEvaluator 作为属性动画估值器
rotationAnimation.setEvaluator(QuaternionEvaluator())
// 设置动画重复无限次播放。
rotationAnimation.repeatCount = ObjectAnimator.INFINITE
rotationAnimation.repeatMode = ObjectAnimator.RESTART
rotationAnimation.interpolator = LinearInterpolator()
rotationAnimation.setAutoCancel(true)
return rotationAnimation
}
}
总结
规则多面体的配置文件有点大,看起来比较费劲,解耦处理的不是很好;不过这个配置文件实现了大部分的功能,在使用的时候可以更少的去关心界面是怎么画出来的,修改适配器即可;如有更好的建议,还请指正
贴出来的示例代码主要阐明关键部分的实现,没有贴出所有文件,如有需要可咨询
搬砖不易,转载请标明出处 https://blog.csdn.net/qq_19154605/article/details/103779594
码云源码下载